325 lines
11 KiB
Go
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"
|
|
}
|
|
}
|