package controllers
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"hacknight/internal/friendli"
"hacknight/models"
"theskyscape.com/repo/skykit"
)
func Projects() (string, skykit.Handler) {
return "projects", &ProjectsController{}
}
type ProjectsController struct {
skykit.Controller
}
func (c *ProjectsController) Setup(app *skykit.Application) {
c.Controller.Setup(app)
http.Handle("/project/{project}", c.Serve("project.html", nil))
// HTMX endpoints
http.HandleFunc("POST /api/projects/create", c.handleCreateProject)
http.HandleFunc("POST /task/create", c.handleCreateTask)
http.HandleFunc("POST /task/{task}/move/{status}", c.handleMoveTask)
http.HandleFunc("POST /tasks/reorder", c.handleReorderTasks)
// Settings endpoints
http.HandleFunc("POST /api/settings/api-key", c.handleSaveAPIKey)
// AI endpoints
http.HandleFunc("POST /ai/generate-tasks", c.handleAIGenerateTasks)
http.HandleFunc("POST /ai/project-summary", c.handleAIProjectSummary)
}
func (c ProjectsController) Handle(r *http.Request) skykit.Handler {
c.Request = r
return &c
}
// Template methods (called lazily from views)
func (c *ProjectsController) CurrentProject() *models.Project {
project, err := models.GetProjectByID(c.PathValue("project"))
if err != nil {
return nil
}
return project
}
func (c *ProjectsController) Tasks() []*models.Task {
project := c.CurrentProject()
if project == nil {
return nil
}
tasks, err := project.GetTasks()
if err != nil {
return nil
}
return tasks
}
func (c *ProjectsController) TodoTasks() []*models.Task {
var filtered []*models.Task
for _, task := range c.Tasks() {
if task.Status == "todo" {
filtered = append(filtered, task)
}
}
return filtered
}
func (c *ProjectsController) InProgressTasks() []*models.Task {
var filtered []*models.Task
for _, task := range c.Tasks() {
if task.Status == "in_progress" {
filtered = append(filtered, task)
}
}
return filtered
}
func (c *ProjectsController) DoneTasks() []*models.Task {
var filtered []*models.Task
for _, task := range c.Tasks() {
if task.Status == "done" {
filtered = append(filtered, task)
}
}
return filtered
}
func (c *ProjectsController) TaskStats() map[string]int {
project := c.CurrentProject()
if project == nil {
return map[string]int{"todo": 0, "in_progress": 0, "done": 0}
}
todo, inProgress, done, err := project.GetTaskStats()
if err != nil {
return map[string]int{"todo": 0, "in_progress": 0, "done": 0}
}
return map[string]int{
"todo": todo,
"in_progress": inProgress,
"done": done,
}
}
// Template methods (called lazily from views)
// HasAPIKey checks if the Friendli API key is configured
func (c *ProjectsController) HasAPIKey() bool {
apiKey, err := models.GetFriendliAPIKey()
log.Println("[ProjectsController] HasAPIKey:", apiKey, err)
if err != nil || apiKey == "" {
return false
}
return true
}
// HTMX Handlers
func (c *ProjectsController) handleSaveAPIKey(w http.ResponseWriter, r *http.Request) {
apiKey := r.FormValue("api_key")
fmt.Printf("[Save API Key] Saving API key (length: %d)\n", len(apiKey))
if apiKey == "" {
fmt.Println("[Save API Key] Error: API key is required")
c.Render(w, r, "error-message.html", "API key is required")
return
}
if err := models.SetFriendliAPIKey(apiKey); err != nil {
fmt.Printf("[Save API Key] Error saving API key: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to save API key: %v", err))
return
}
fmt.Println("[Save API Key] Success: API key saved")
c.Refresh(w, r)
}
func (c *ProjectsController) handleCreateProject(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
description := r.FormValue("description")
fmt.Printf("[Create Project] Name: %s\n", name)
if name == "" {
fmt.Println("[Create Project] Error: Name is required")
c.Render(w, r, "error-message.html", "Project name is required")
return
}
project, err := models.CreateProject(name, description)
if err != nil {
fmt.Printf("[Create Project] Error creating project: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to create project: %v", err))
return
}
fmt.Printf("[Create Project] Success: Created project %s (ID: %s)\n", project.Name, project.ID)
c.Redirect(w, r, os.Getenv("HOST_PREFIX")+"/project/"+project.ID)
}
func (c *ProjectsController) handleCreateTask(w http.ResponseWriter, r *http.Request) {
projectID := r.FormValue("project_id")
title := r.FormValue("title")
description := r.FormValue("description")
priority := r.FormValue("priority")
fmt.Printf("[Create Task] Project: %s, Title: %s\n", projectID, title)
if title == "" {
fmt.Println("[Create Task] Error: Title is required")
c.Render(w, r, "error-message.html", "Task title is required")
return
}
if priority == "" {
priority = "medium"
}
task, err := models.CreateTask(projectID, title, description, "todo", priority)
if err != nil {
fmt.Printf("[Create Task] Error creating task: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to create task: %v", err))
return
}
fmt.Printf("[Create Task] Success: Created task %s (ID: %s)\n", task.Title, task.ID)
c.Refresh(w, r)
}
func (c *ProjectsController) handleMoveTask(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("task")
newStatus := r.PathValue("status")
fmt.Printf("[Move Task] Task: %s, New Status: %s\n", taskID, newStatus)
task, err := models.GetTaskByID(taskID)
if err != nil || task == nil {
fmt.Printf("[Move Task] Error: Task not found - %v\n", err)
c.Render(w, r, "error-message.html", "Task not found")
return
}
if err := task.UpdateStatus(newStatus); err != nil {
fmt.Printf("[Move Task] Error updating status: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to move task: %v", err))
return
}
fmt.Printf("[Move Task] Success: Moved task %s to %s\n", task.Title, newStatus)
c.Refresh(w, r)
}
func (c *ProjectsController) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
// Parse form to get task IDs in new order
if err := r.ParseForm(); err != nil {
fmt.Printf("[Reorder Tasks] Error parsing form: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to parse request: %v", err))
return
}
taskIDs := r.Form["task"]
fmt.Printf("[Reorder Tasks] Reordering %d tasks\n", len(taskIDs))
// Update position for each task
for i, taskID := range taskIDs {
task, err := models.GetTaskByID(taskID)
if err != nil || task == nil {
fmt.Printf("[Reorder Tasks] Warning: Skipping task %s - not found\n", taskID)
continue
}
if err := task.UpdatePosition(i); err != nil {
fmt.Printf("[Reorder Tasks] Error updating position for task %s: %v\n", taskID, err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to reorder tasks: %v", err))
return
}
}
fmt.Println("[Reorder Tasks] Success: Tasks reordered")
w.WriteHeader(http.StatusOK)
}
// AI-powered handlers
func (c *ProjectsController) handleAIGenerateTasks(w http.ResponseWriter, r *http.Request) {
projectID := r.FormValue("project_id")
fmt.Printf("[AI Generate Tasks] Project ID: %s\n", projectID)
project, err := models.GetProjectByID(projectID)
if err != nil || project == nil {
fmt.Printf("[AI Generate Tasks] Error: Project not found - %v\n", err)
c.Render(w, r, "error-message.html", "Project not found")
return
}
// Check for API key
apiKey, err := models.GetFriendliAPIKey()
if err != nil || apiKey == "" {
fmt.Println("[AI Generate Tasks] Error: FRIENDLI_API_KEY not configured")
c.Render(w, r, "error-message.html", "Friendli API key not configured. Please set it in the settings.")
return
}
fmt.Printf("[AI Generate Tasks] Creating Friendli client for project: %s\n", project.Name)
client, err := friendli.NewClient(apiKey)
if err != nil {
fmt.Printf("[AI Generate Tasks] Error creating client: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to create AI client: %v", err))
return
}
prompt := fmt.Sprintf(`You are a project manager AI. Analyze this project and generate 5-8 specific, actionable tasks.
Project: %s
Description: %s
For each task, provide:
- A clear, actionable title (brief, starts with a verb)
- A detailed description (2-3 sentences explaining what needs to be done)
- Priority level (low, medium, or high based on importance and dependencies)
Format your response as a numbered list with clear sections for each task.
Be specific and practical - these tasks should be ready to implement.`, project.Name, project.Description)
req := friendli.NewChatCompletionRequest(
"meta-llama/Llama-3.1-8B-Instruct",
[]friendli.Message{
friendli.NewSystemMessage("You are a helpful project management AI assistant that breaks down projects into actionable tasks."),
friendli.NewUserMessage(prompt),
},
).WithTemperature(0.7).WithMaxTokens(1000)
fmt.Println("[AI Generate Tasks] Starting streaming request...")
stream, err := client.Chat.CreateCompletionStream(context.Background(), req)
if err != nil {
fmt.Printf("[AI Generate Tasks] Error creating stream: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to start AI generation: %v", err))
return
}
defer stream.Close()
// Set up SSE streaming
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
fmt.Println("[AI Generate Tasks] Error: Streaming not supported")
c.Render(w, r, "error-message.html", "Streaming not supported by server")
return
}
// Start with a loading indicator
fmt.Fprint(w, `<div class="prose prose-invert max-w-none">`)
fmt.Fprint(w, `<div class="flex items-center gap-2 mb-4"><span class="loading loading-dots loading-md"></span><span>AI is analyzing your project...</span></div>`)
flusher.Flush()
var fullContent strings.Builder
tokenCount := 0
for {
chunk, err := stream.Recv()
if err == io.EOF {
fmt.Printf("[AI Generate Tasks] Stream completed. Total tokens: %d\n", tokenCount)
break
}
if err != nil {
fmt.Printf("[AI Generate Tasks] Stream error: %v\n", err)
fmt.Fprintf(w, `<div class="alert alert-error mt-4"><span>Stream error: %s</span></div>`, err.Error())
flusher.Flush()
return
}
tokenCount++
if len(chunk.Choices) > 0 {
content := chunk.Choices[0].Delta.Content
fullContent.WriteString(content)
// Send content as it arrives (escape for HTML)
escaped := strings.ReplaceAll(content, "<", "<")
escaped = strings.ReplaceAll(escaped, ">", ">")
escaped = strings.ReplaceAll(escaped, "\n", "<br>")
fmt.Fprint(w, escaped)
flusher.Flush()
}
}
fmt.Fprint(w, `</div>`)
fmt.Fprint(w, `<div class="alert alert-success mt-4"><span>✅ Task generation complete! Review the suggestions above and create tasks manually.</span></div>`)
flusher.Flush()
}
func (c *ProjectsController) handleAIProjectSummary(w http.ResponseWriter, r *http.Request) {
projectID := r.FormValue("project_id")
fmt.Printf("[AI Project Summary] Project ID: %s\n", projectID)
project, err := models.GetProjectByID(projectID)
if err != nil || project == nil {
fmt.Printf("[AI Project Summary] Error: Project not found - %v\n", err)
c.Render(w, r, "error-message.html", "Project not found")
return
}
tasks, err := project.GetTasks()
if err != nil {
fmt.Printf("[AI Project Summary] Error getting tasks: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to load project tasks: %v", err))
return
}
// Check for API key
apiKey, err := models.GetFriendliAPIKey()
if err != nil || apiKey == "" {
fmt.Println("[AI Project Summary] Error: FRIENDLI_API_KEY not configured")
c.Render(w, r, "error-message.html", "Friendli API key not configured. Please set it in the settings.")
return
}
fmt.Printf("[AI Project Summary] Creating Friendli client for project: %s (Tasks: %d)\n", project.Name, len(tasks))
client, err := friendli.NewClient(apiKey)
if err != nil {
fmt.Printf("[AI Project Summary] Error creating client: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to create AI client: %v", err))
return
}
// Build task summary
taskSummary := ""
for _, task := range tasks {
taskSummary += fmt.Sprintf("- [%s] [%s] %s: %s\n",
task.Status, task.Priority, task.Title, task.Description)
}
if taskSummary == "" {
taskSummary = "No tasks created yet."
}
prompt := fmt.Sprintf(`You are a senior project manager AI. Analyze this project and provide an executive summary.
Project: %s
Description: %s
Current Tasks:
%s
Provide a comprehensive analysis with these sections:
## 📊 Overall Progress
- Current completion percentage and health status
- Brief assessment of project trajectory
## ✅ Key Achievements
- What has been completed successfully
- Major milestones reached
## 🚧 Current Work
- Tasks currently in progress
- Active focus areas
## ⚠️ Potential Blockers
- Tasks that might be stuck or at risk
- Dependencies or challenges to address
## 🎯 Recommended Next Steps
- Top 3-5 prioritized actions
- Strategic suggestions for moving forward
## ⏱️ Timeline Estimate
- Realistic completion estimate based on current progress
- Factors that could accelerate or delay completion
Keep the tone professional but encouraging. Be specific and actionable.`,
project.Name, project.Description, taskSummary)
req := friendli.NewChatCompletionRequest(
"meta-llama/Llama-3.1-8B-Instruct",
[]friendli.Message{
friendli.NewSystemMessage("You are an experienced project manager providing insightful analysis and strategic guidance."),
friendli.NewUserMessage(prompt),
},
).WithTemperature(0.4).WithMaxTokens(1200)
fmt.Println("[AI Project Summary] Starting streaming request...")
stream, err := client.Chat.CreateCompletionStream(context.Background(), req)
if err != nil {
fmt.Printf("[AI Project Summary] Error creating stream: %v\n", err)
c.Render(w, r, "error-message.html", fmt.Sprintf("Failed to start AI analysis: %v", err))
return
}
defer stream.Close()
// Set up SSE streaming
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
fmt.Println("[AI Project Summary] Error: Streaming not supported")
c.Render(w, r, "error-message.html", "Streaming not supported by server")
return
}
// Start with a loading indicator
fmt.Fprint(w, `<div class="flex items-center gap-2 mb-4"><span class="loading loading-dots loading-md"></span><span>AI is analyzing your project...</span></div>`)
flusher.Flush()
var fullContent strings.Builder
tokenCount := 0
for {
chunk, err := stream.Recv()
if err == io.EOF {
fmt.Printf("[AI Project Summary] Stream completed. Total tokens: %d\n", tokenCount)
break
}
if err != nil {
fmt.Printf("[AI Project Summary] Stream error: %v\n", err)
fmt.Fprintf(w, `<div class="alert alert-error mt-4"><span>Stream error: %s</span></div>`, err.Error())
flusher.Flush()
return
}
tokenCount++
if len(chunk.Choices) > 0 {
content := chunk.Choices[0].Delta.Content
fullContent.WriteString(content)
// Send content as markdown-rendered HTML
// For simplicity, we'll do basic markdown conversion
escaped := strings.ReplaceAll(content, "<", "<")
escaped = strings.ReplaceAll(escaped, ">", ">")
// Convert markdown headers
escaped = strings.ReplaceAll(escaped, "## ", "<h2 class='text-xl font-bold mt-4 mb-2'>")
escaped = strings.ReplaceAll(escaped, "\n\n", "</h2><p>")
escaped = strings.ReplaceAll(escaped, "\n", "<br>")
fmt.Fprint(w, escaped)
flusher.Flush()
}
}
fmt.Fprint(w, `<div class="divider"></div>`)
fmt.Fprint(w, `<div class="alert alert-info"><span>💡 This analysis was generated by AI. Use it as guidance for decision-making.</span></div>`)
flusher.Flush()
}