Merge pull request #37 from dj-oyu/test-ahead

feat: research task system
This commit is contained in:
dj-oyu 2026-03-15 15:16:18 +09:00 committed by GitHub
commit e909b56875
19 changed files with 3778 additions and 2 deletions

View file

@ -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
View 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),
})
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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),
})
}

View file

@ -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

File diff suppressed because it is too large Load diff

View 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)}`,
)
}

View file

@ -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,
},
], ],
}, },
{ {

View 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>
)
}

View 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>
)
}

View file

@ -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"
}
} }
} }
} }

View file

@ -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": "已取消"
}
} }
} }
} }

View file

@ -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)

View 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 />
}

View 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} />
}