Files
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

325 lines
11 KiB
Go

// Package handlers (pages.go) replaces internal/handlers/pages.go: the
// main chat page and conversation management. Every h.DB call becomes
// one gRPC call to the conversation service (plus billing for the plan
// badge, knowledge for the indexed-files list, and workspace for the
// files panel).
package handlers
import (
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
knowledgev1 "enkly/gen/knowledge/v1"
"enkly/pkg/renderkit"
)
// StreamView is what the "assistant_stream" template needs to attach a
// live assistant bubble to an in-flight run.
type StreamView struct {
StreamID string
CSRFToken string
}
// messageView is a conversation message plus its rendered HTML — the
// gateway's local equivalent of the monolith's models.Message (which
// carried a RenderedHTML field filled in by prepareMessages).
type messageView struct {
Role string
ToolName string
Content string // raw text, kept alongside RenderedHTML for deliverables.go's producedPath
RenderedHTML template.HTML
}
// conversationView is the gateway's local equivalent of
// models.Conversation, just the fields index.html actually uses.
type conversationView struct {
ID int64
Title string
}
// indexData is the template data for index.html — identical field set
// to the monolith's indexData.
type indexData struct {
ConversationTitle string
ConversationID int64
Conversations []conversationView
Messages []messageView
Files []string
CSRFToken string
UserEmail string
Theme string
Lang string
PlanName string
ActiveStream *StreamView
WorkspaceFiles []workspaceFileView
UploadMessage string
UploadError string
}
// Index (GET /) opens the user's most recently active conversation,
// creating the first one if they have none.
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx := r.Context()
conv, err := h.Conversation.MostRecentConversation(ctx, &conversationv1.MostRecentConversationRequest{UserId: user.GetId()})
if isNotFound(err) {
conv, err = h.Conversation.CreateConversation(ctx, &conversationv1.CreateConversationRequest{UserId: user.GetId()})
}
if err != nil {
h.serverError(w, "open conversation", err)
return
}
h.renderConversation(w, r, conv)
}
// SwitchConversation (GET /c/{id}) shows a specific conversation.
func (h *Handlers) SwitchConversation(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
conv, err := h.Conversation.GetConversation(r.Context(), &conversationv1.GetConversationRequest{UserId: user.GetId(), ConversationId: id})
if isNotFound(err) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get conversation", err)
return
}
h.renderConversation(w, r, conv)
}
func isNotFound(err error) bool {
if err == nil {
return false
}
st, ok := status.FromError(err)
return ok && st.Code() == codes.NotFound
}
func (h *Handlers) renderConversation(w http.ResponseWriter, r *http.Request, conv *conversationv1.Conversation) {
ctx := r.Context()
user := userFrom(r)
convsResp, err := h.Conversation.ListConversations(ctx, &conversationv1.ListConversationsRequest{UserId: user.GetId()})
if err != nil {
h.serverError(w, "list conversations", err)
return
}
msgsResp, err := h.Conversation.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: conv.GetId()})
if err != nil {
h.serverError(w, "list messages", err)
return
}
var files []string
if resp, err := h.Knowledge.ListIndexedFiles(ctx, &knowledgev1.ListIndexedFilesRequest{Limit: 200}); err != nil {
h.Logger.Warn("list indexed files", "err", err)
} else {
files = resp.GetFilePaths()
}
workspaceFiles, err := h.listWorkspaceFilesCtx(r)
if err != nil {
h.Logger.Warn("list workspace files", "err", err)
}
planName := "Free"
if sub, err := h.Billing.GetOrCreateSubscription(ctx, &billingv1.GetOrCreateSubscriptionRequest{UserId: user.GetId()}); err != nil {
h.Logger.Warn("load subscription", "err", err)
} else {
planName = planDisplayName(sub.GetPlan())
}
convs := make([]conversationView, 0, len(convsResp.GetConversations()))
for _, c := range convsResp.GetConversations() {
convs = append(convs, conversationView{ID: c.GetId(), Title: c.GetTitle()})
}
csrf := h.csrfToken(w, r)
data := indexData{
ConversationTitle: conv.GetTitle(),
ConversationID: conv.GetId(),
Conversations: convs,
Messages: h.withDeliverables(ctx, h.prepareMessages(msgsResp.GetMessages())),
Files: files,
CSRFToken: csrf,
UserEmail: user.GetEmail(),
Theme: themeFrom(r),
Lang: string(localeFrom(r)),
PlanName: planName,
WorkspaceFiles: workspaceFiles,
}
// NOTE: reattaching to a still-in-flight run after a page reload
// (the monolith's h.Streams.activeFor lookup) is intentionally not
// wired here — it would need a new "is there an active stream for
// this conversation" agent RPC with no use case elsewhere in the
// API. The reply still completes and is persisted normally; a
// reload mid-reply just shows it on the next load instead of
// resuming the live bubble. See ARCHITECTURE.md's known gaps.
h.render(w, http.StatusOK, "index", data)
}
// NewConversation (POST /conversations/new).
func (h *Handlers) NewConversation(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
ctx := r.Context()
if latest, err := h.Conversation.MostRecentConversation(ctx, &conversationv1.MostRecentConversationRequest{UserId: user.GetId()}); err == nil {
if n, err := h.Conversation.CountMessages(ctx, &conversationv1.CountMessagesRequest{ConversationId: latest.GetId()}); err == nil && n.GetCount() == 0 {
http.Redirect(w, r, fmt.Sprintf("/c/%d", latest.GetId()), http.StatusSeeOther)
return
}
}
conv, err := h.Conversation.CreateConversation(ctx, &conversationv1.CreateConversationRequest{UserId: user.GetId()})
if err != nil {
h.serverError(w, "create conversation", err)
return
}
http.Redirect(w, r, fmt.Sprintf("/c/%d", conv.GetId()), http.StatusSeeOther)
}
// RenameConversation (POST /conversations/{id}/rename).
func (h *Handlers) RenameConversation(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
title := cleanTitle(r.PostFormValue("title"), maxTitleChars)
if title == "" {
http.Redirect(w, r, fmt.Sprintf("/c/%d", id), http.StatusSeeOther)
return
}
_, err = h.Conversation.RenameConversation(r.Context(), &conversationv1.RenameConversationRequest{UserId: user.GetId(), ConversationId: id, Title: title})
switch {
case err == nil:
http.Redirect(w, r, fmt.Sprintf("/c/%d", id), http.StatusSeeOther)
case isNotFound(err):
http.NotFound(w, r)
default:
h.serverError(w, "rename conversation", err)
}
}
// DeleteConversationHandler (POST /conversations/{id}/delete).
func (h *Handlers) DeleteConversationHandler(w http.ResponseWriter, r *http.Request) {
user := userFrom(r)
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
_, err = h.Conversation.DeleteConversation(r.Context(), &conversationv1.DeleteConversationRequest{UserId: user.GetId(), ConversationId: id})
switch {
case err == nil:
// NOTE: the monolith also cancelled any in-flight run on this
// conversation here (h.Streams.cancelConversation), so a
// stream still writing wouldn't hit AddMessage failures on a
// deleted row. Agent's API has no "stop by conversation" RPC
// (only by stream_id — see agent.proto), so that cleanup is
// not wired here; a straggling run's writes will simply fail
// and be logged by the agent service instead of being
// pre-empted. See ARCHITECTURE.md's known gaps.
http.Redirect(w, r, "/", http.StatusSeeOther)
case isNotFound(err):
http.NotFound(w, r)
default:
h.serverError(w, "delete conversation", err)
}
}
func (h *Handlers) serverError(w http.ResponseWriter, what string, err error) {
h.Logger.Error(what, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
// ---------------------------------------------------------------------
// View helpers
// ---------------------------------------------------------------------
const maxToolBodyChars = 12_000
// prepareMessages fills RenderedHTML for template rendering: Markdown
// for assistant replies, escaped text (plus any attachment chips —
// see attachments.go's renderUserMessage) for user prompts, and an
// escaped <pre> for tool output. Identical logic to the monolith's,
// now building local messageView values instead of mutating
// models.Message.
func (h *Handlers) prepareMessages(msgs []*conversationv1.Message) []messageView {
out := make([]messageView, len(msgs))
for i, m := range msgs {
mv := messageView{Role: roleString(m.GetRole()), ToolName: m.GetToolName(), Content: m.GetContent()}
switch m.GetRole() {
case conversationv1.Role_ROLE_ASSISTANT:
mv.RenderedHTML = template.HTML(renderkit.Markdown(m.GetContent())) // #nosec G203 -- sanitized by renderkit
case conversationv1.Role_ROLE_TOOL:
mv.RenderedHTML = toolBodyHTML(m.GetContent())
default:
mv.RenderedHTML = h.renderUserMessage(m.GetContent())
}
out[i] = mv
}
return out
}
func roleString(r conversationv1.Role) string {
switch r {
case conversationv1.Role_ROLE_USER:
return "user"
case conversationv1.Role_ROLE_ASSISTANT:
return "assistant"
case conversationv1.Role_ROLE_TOOL:
return "tool"
default:
return "system"
}
}
func toolBodyHTML(content string) template.HTML {
if strings.TrimSpace(content) == "" {
return template.HTML(`<pre class="tool-output">(no output)</pre>`)
}
if len(content) > maxToolBodyChars {
cut := maxToolBodyChars
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
content = content[:cut] + fmt.Sprintf("\n… [%d more characters not shown]", len(content)-cut)
}
return template.HTML(`<pre class="tool-output">` + template.HTMLEscapeString(content) + `</pre>`) // #nosec G203 -- escaped
}
func cleanTitle(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) <= max {
return s
}
runes := []rune(s)
return strings.TrimSpace(string(runes[:max-1])) + "…"
}
func planDisplayName(p billingv1.Plan) string {
switch p {
case billingv1.Plan_PLAN_BASIC:
return "Basic"
case billingv1.Plan_PLAN_PRO:
return "Pro"
case billingv1.Plan_PLAN_PREMIUM:
return "Premium"
default:
return "Free"
}
}