Merge pull request #37 from dj-oyu/test-ahead
feat: research task system
This commit is contained in:
commit
e909b56875
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/miniapp"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/research"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/stats"
|
||||
|
|
@ -331,6 +332,19 @@ func setupAndStartServices(
|
|||
devPreviewTool := tools.NewDevPreviewTool(handler)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/orch"
|
||||
"github.com/sipeed/picoclaw/pkg/research"
|
||||
)
|
||||
|
||||
//go:generate bun run --cwd frontend build
|
||||
|
|
@ -38,6 +39,7 @@ type Handler struct {
|
|||
allowList []string
|
||||
workspace string
|
||||
orchBroadcaster *orch.Broadcaster
|
||||
researchStore *research.ResearchStore
|
||||
|
||||
devMu sync.RWMutex
|
||||
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/dev/console", h.apiDevConsole)
|
||||
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) {
|
||||
|
|
|
|||
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)
|
||||
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,
|
||||
IconChevronsDown,
|
||||
IconChevronsUp,
|
||||
IconFileSearch,
|
||||
IconKey,
|
||||
IconListDetails,
|
||||
IconMessageCircle,
|
||||
|
|
@ -132,6 +133,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
icon: IconTools,
|
||||
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_less_channels": "Less",
|
||||
"config": "Config",
|
||||
"logs": "Logs"
|
||||
"logs": "Logs",
|
||||
"research": "Research"
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "How can I help you today?",
|
||||
|
|
@ -455,6 +456,36 @@
|
|||
"logs": {
|
||||
"clear": "Clear 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_less_channels": "收起",
|
||||
"config": "配置",
|
||||
"logs": "日志"
|
||||
"logs": "日志",
|
||||
"research": "调研"
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "今天我能为您做些什么?",
|
||||
|
|
@ -455,6 +456,36 @@
|
|||
"logs": {
|
||||
"clear": "清空日志",
|
||||
"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.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ResearchRouteImport } from './routes/research'
|
||||
import { Route as ModelsRouteImport } from './routes/models'
|
||||
import { Route as LogsRouteImport } from './routes/logs'
|
||||
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 ChannelsRouteRouteImport } from './routes/channels/route'
|
||||
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 ChannelsNameRouteImport } from './routes/channels/$name'
|
||||
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
||||
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
||||
|
||||
const ResearchRoute = ResearchRouteImport.update({
|
||||
id: '/research',
|
||||
path: '/research',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ModelsRoute = ModelsRouteImport.update({
|
||||
id: '/models',
|
||||
path: '/models',
|
||||
|
|
@ -56,6 +63,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ResearchTaskIdRoute = ResearchTaskIdRouteImport.update({
|
||||
id: '/$taskId',
|
||||
path: '/$taskId',
|
||||
getParentRoute: () => ResearchRoute,
|
||||
} as any)
|
||||
const ConfigRawRoute = ConfigRawRouteImport.update({
|
||||
id: '/raw',
|
||||
path: '/raw',
|
||||
|
|
@ -85,10 +97,12 @@ export interface FileRoutesByFullPath {
|
|||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/research': typeof ResearchRouteWithChildren
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
'/agent/tools': typeof AgentToolsRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
'/research/$taskId': typeof ResearchTaskIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -98,10 +112,12 @@ export interface FileRoutesByTo {
|
|||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/research': typeof ResearchRouteWithChildren
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
'/agent/tools': typeof AgentToolsRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
'/research/$taskId': typeof ResearchTaskIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
|
|
@ -112,10 +128,12 @@ export interface FileRoutesById {
|
|||
'/credentials': typeof CredentialsRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/models': typeof ModelsRoute
|
||||
'/research': typeof ResearchRouteWithChildren
|
||||
'/agent/skills': typeof AgentSkillsRoute
|
||||
'/agent/tools': typeof AgentToolsRoute
|
||||
'/channels/$name': typeof ChannelsNameRoute
|
||||
'/config/raw': typeof ConfigRawRoute
|
||||
'/research/$taskId': typeof ResearchTaskIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
|
|
@ -127,10 +145,12 @@ export interface FileRouteTypes {
|
|||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/research'
|
||||
| '/agent/skills'
|
||||
| '/agent/tools'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
| '/research/$taskId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -140,10 +160,12 @@ export interface FileRouteTypes {
|
|||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/research'
|
||||
| '/agent/skills'
|
||||
| '/agent/tools'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
| '/research/$taskId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
|
|
@ -153,10 +175,12 @@ export interface FileRouteTypes {
|
|||
| '/credentials'
|
||||
| '/logs'
|
||||
| '/models'
|
||||
| '/research'
|
||||
| '/agent/skills'
|
||||
| '/agent/tools'
|
||||
| '/channels/$name'
|
||||
| '/config/raw'
|
||||
| '/research/$taskId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
|
|
@ -167,10 +191,18 @@ export interface RootRouteChildren {
|
|||
CredentialsRoute: typeof CredentialsRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
ModelsRoute: typeof ModelsRoute
|
||||
ResearchRoute: typeof ResearchRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/research': {
|
||||
id: '/research'
|
||||
path: '/research'
|
||||
fullPath: '/research'
|
||||
preLoaderRoute: typeof ResearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/models': {
|
||||
id: '/models'
|
||||
path: '/models'
|
||||
|
|
@ -220,6 +252,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/research/$taskId': {
|
||||
id: '/research/$taskId'
|
||||
path: '/$taskId'
|
||||
fullPath: '/research/$taskId'
|
||||
preLoaderRoute: typeof ResearchTaskIdRouteImport
|
||||
parentRoute: typeof ResearchRoute
|
||||
}
|
||||
'/config/raw': {
|
||||
id: '/config/raw'
|
||||
path: '/raw'
|
||||
|
|
@ -286,6 +325,18 @@ const ConfigRouteChildren: ConfigRouteChildren = {
|
|||
const ConfigRouteWithChildren =
|
||||
ConfigRoute._addFileChildren(ConfigRouteChildren)
|
||||
|
||||
interface ResearchRouteChildren {
|
||||
ResearchTaskIdRoute: typeof ResearchTaskIdRoute
|
||||
}
|
||||
|
||||
const ResearchRouteChildren: ResearchRouteChildren = {
|
||||
ResearchTaskIdRoute: ResearchTaskIdRoute,
|
||||
}
|
||||
|
||||
const ResearchRouteWithChildren = ResearchRoute._addFileChildren(
|
||||
ResearchRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
||||
|
|
@ -294,6 +345,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
CredentialsRoute: CredentialsRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
ModelsRoute: ModelsRoute,
|
||||
ResearchRoute: ResearchRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._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