projects.go
go
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, "<", "&lt;")
			escaped = strings.ReplaceAll(escaped, ">", "&gt;")
			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, "<", "&lt;")
			escaped = strings.ReplaceAll(escaped, ">", "&gt;")
			// 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()
}
No comments yet.