mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 20:33:15 +00:00
后端(handler_memory.go 重写 handleKnowledge):
- ★ multipart 分支原来无条件 file.Read → string(buf[:n]) → 当 Markdown 存进
content.md:传一张 PNG 得到的是一份乱码文本知识,还在 .index.json 占一份
preview,且**没有任何迹象**表明出了问题。
现在按 http.DetectContentType 探测的**真实类型**分流(不信客户端声明的
Content-Type——谎报 text/plain 的 PNG 在测试里是真实场景):
媒体入 CAS 按 digest 挂条目 / 文本校验 UTF-8 后存正文 / 都不是则 400 明确
拒绝并回传 rejected 清单。
- 状态码语义修正:此前 POST/GET 一律 500、DELETE 一律 404,把「名称非法」
这类调用方能自己纠正的错报成服务器故障。现按 errors.Is 分流
400/404/503。
- 搜索支持 category 与 limit;返回 knowledgeView(不泄露服务端绝对路径,
不回传几百 KB 的 Dense 浮点数组)。
- 列表端点补 dense 状态(前端据此提示多模态是否就绪)。
- 媒体存储未接线时上传图片返回 503,而非退化成把二进制当文本存。
前端(dashboard.js):
- 搜索结果从 <pre>{JSON}</pre> 改为结构化渲染(名称/体积/预览/媒体标记
+ 每条删除按钮)。此前前端根本没有删除入口。
- 新增分类输入框(走 category 参数)、多文件上传。
- 计数改实时:原先读 state.kernel 快照,知识条目经工具/上传增删后不会变
(实测创建完仍显示 "-")。切到知识面板时拉 /knowledge 的真实 names.length。
- 顶部输入框变多文件;显示多模态就绪状态(ready/total)。
565 lines
18 KiB
Go
565 lines
18 KiB
Go
package webui
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// 记忆面:图记忆 / 文档记忆 / 文本记忆 / 知识库 / LLM 源 / 变更追踪。
|
||
|
||
func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
|
||
if h.memory == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"})
|
||
return
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
userInput := r.URL.Query().Get("q")
|
||
keywords := strings.Split(userInput, ",")
|
||
depth, _ := strconv.Atoi(r.URL.Query().Get("depth"))
|
||
if depth <= 0 {
|
||
depth = 2
|
||
}
|
||
entities, relations, err := h.memory.Recall(keywords, depth)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"entities": entities, "relations": relations})
|
||
case http.MethodPost:
|
||
var req struct {
|
||
Triples []sdk.Triple `json:"triples"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||
return
|
||
}
|
||
if err := h.memory.Commit(req.Triples); err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "committed", "committed": len(req.Triples)})
|
||
case http.MethodDelete:
|
||
var req struct {
|
||
Criteria map[string]string `json:"criteria"`
|
||
Mode string `json:"mode"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||
return
|
||
}
|
||
deleted, err := h.memory.Purge(req.Criteria, req.Mode)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted})
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if h.indexer == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"})
|
||
return
|
||
}
|
||
userInput := r.URL.Query().Get("q")
|
||
injected, err := h.indexer.BuildContext(userInput)
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"context": h.indexer.FormatContext(injected),
|
||
"summary": injected.Summary,
|
||
"entities": injected.Entities,
|
||
"token_estimate": injected.TokenEstimate,
|
||
"tool_prompt": h.indexer.BuildToolPrompt(),
|
||
})
|
||
}
|
||
|
||
func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if h.indexer == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "indexer not available"})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"tools": h.indexer.GetToolDefinitions(),
|
||
"tool_prompt": h.indexer.BuildToolPrompt(),
|
||
})
|
||
}
|
||
|
||
func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if h.memory == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"})
|
||
return
|
||
}
|
||
data, err := h.memory.GraphData()
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data})
|
||
}
|
||
|
||
// knowledgeWriteReq 是知识写入请求体(JSON 分支)。
|
||
type knowledgeWriteReq struct {
|
||
Name string `json:"name"`
|
||
Content string `json:"content"`
|
||
Media []sdk.KnowledgeMediaRef `json:"media,omitempty"`
|
||
}
|
||
|
||
// knowledgeView 是返回给前端的知识条目视图。
|
||
//
|
||
// 为何不让前端直接吃 *knowledge.Knowledge:那个结构里有 Path(服务端绝对
|
||
// 路径,不该外泄)、Dense(几百 KB 浮点数组)。前端只需要 name/category/
|
||
// tags/size/updated_at/media 摘要。
|
||
type knowledgeView struct {
|
||
Name string `json:"name"`
|
||
Category string `json:"category,omitempty"`
|
||
Preview string `json:"preview"`
|
||
Size int `json:"size"`
|
||
Tags []string `json:"tags,omitempty"`
|
||
UpdatedAt string `json:"updated_at,omitempty"`
|
||
Media []knowledgeMediaView `json:"media,omitempty"`
|
||
}
|
||
|
||
type knowledgeMediaView struct {
|
||
Digest string `json:"digest"`
|
||
MIME string `json:"mime"`
|
||
Kind string `json:"kind,omitempty"`
|
||
}
|
||
|
||
// knowledgeStatusFor 把内核的 ErrInvalidName / ErrNotFound 映射到正确状态码。
|
||
//
|
||
// 此前一律 500:把「名称非法」「不存在」这种**调用方能自己纠正**的错报成
|
||
// 服务器故障,前端无从区分该改请求还是该报服务器挂了。
|
||
func knowledgeStatusFor(err error) (int, string) {
|
||
switch {
|
||
case errors.Is(err, knowledge.ErrInvalidName):
|
||
return http.StatusBadRequest, err.Error()
|
||
case errors.Is(err, knowledge.ErrNotFound):
|
||
return http.StatusNotFound, err.Error()
|
||
case errors.Is(err, sdk.ErrMediaUnavailable):
|
||
return http.StatusServiceUnavailable, err.Error()
|
||
default:
|
||
return http.StatusInternalServerError, err.Error()
|
||
}
|
||
}
|
||
|
||
func toKnowledgeView(k *pubsdk.Knowledge, name string) knowledgeView {
|
||
v := knowledgeView{Name: name}
|
||
if k.Content == "" {
|
||
return v
|
||
}
|
||
// 预览按 rune 截断,避免把多字节字符切成乱码
|
||
r := []rune(k.Content)
|
||
if len(r) > 200 {
|
||
v.Preview = string(r[:200]) + "..."
|
||
} else {
|
||
v.Preview = k.Content
|
||
}
|
||
v.Size = len(k.Content)
|
||
return v
|
||
}
|
||
|
||
func (h *Handler) handleKnowledge(w http.ResponseWriter, r *http.Request) {
|
||
if h.knowledge == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "knowledge not available"})
|
||
return
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
q := r.URL.Query()
|
||
// category 为空 = 全库;非空则限定在该分类子树内(前缀匹配)
|
||
category := q.Get("category")
|
||
if query := strings.TrimSpace(q.Get("q")); query != "" {
|
||
limit := 10
|
||
if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 && v <= 100 {
|
||
limit = v
|
||
}
|
||
var results []*pubsdk.Knowledge
|
||
var err error
|
||
if scoped, ok := h.knowledge.(interface {
|
||
SearchIn(query, category string, topK int) ([]*pubsdk.Knowledge, error)
|
||
}); ok {
|
||
results, err = scoped.SearchIn(query, category, limit)
|
||
} else {
|
||
results, err = h.knowledge.Search(query, limit)
|
||
}
|
||
if err != nil {
|
||
code, msg := knowledgeStatusFor(err)
|
||
writeJSON(w, code, map[string]string{"error": msg})
|
||
return
|
||
}
|
||
views := make([]knowledgeView, 0, len(results))
|
||
for _, k := range results {
|
||
views = append(views, toKnowledgeView(k, k.Name))
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": views, "category": category})
|
||
return
|
||
}
|
||
names, err := h.knowledge.List()
|
||
if err != nil {
|
||
code, msg := knowledgeStatusFor(err)
|
||
writeJSON(w, code, map[string]string{"error": msg})
|
||
return
|
||
}
|
||
if names == nil {
|
||
names = []string{}
|
||
}
|
||
stats := h.knowledge.Stats()
|
||
payload := map[string]interface{}{"names": names, "stats": stats}
|
||
if ds, ok := h.knowledge.(interface {
|
||
DenseStats() map[string]interface{}
|
||
}); ok {
|
||
payload["dense"] = ds.DenseStats()
|
||
}
|
||
writeJSON(w, http.StatusOK, payload)
|
||
|
||
case http.MethodPost:
|
||
// multipart 分支:**先看它到底是什么**。
|
||
//
|
||
// 旧实现无论传什么都把字节 utf-8 强转后当 Markdown 存进 content.md:
|
||
// 上传一张 PNG 得到的是一份乱码文本知识,还会在 .index.json 里占一份
|
||
// preview,且没有任何迹象表明出了问题。现在改为:
|
||
// - 文本类(text/* 或 JSON 字节)→ 走原路径存正文
|
||
// - 媒体类(image/audio/video)→ 入 media CAS,按 digest 挂到条目上
|
||
ct := r.Header.Get("Content-Type")
|
||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||
h.handleKnowledgeUpload(w, r)
|
||
return
|
||
}
|
||
|
||
var req knowledgeWriteReq
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Content) == "" && len(req.Media) == 0 {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "content or media is required"})
|
||
return
|
||
}
|
||
if err := h.knowledge.AddWithMedia(req.Name, req.Content, req.Media); err != nil {
|
||
code, msg := knowledgeStatusFor(err)
|
||
writeJSON(w, code, map[string]string{"error": msg})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||
"status": "created", "name": req.Name, "media": len(req.Media),
|
||
})
|
||
|
||
case http.MethodDelete:
|
||
name := r.URL.Query().Get("name")
|
||
if name == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name query param required"})
|
||
return
|
||
}
|
||
if err := h.knowledge.Remove(name); err != nil {
|
||
// 之前所有失败一律 404,包括名称非法(400 的事)与真实 IO 错误
|
||
// (500 的事)。"啥都没删"和"服务器坏了"被混为一谈。
|
||
code, msg := knowledgeStatusFor(err)
|
||
writeJSON(w, code, map[string]string{"error": msg})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "deleted", "name": name})
|
||
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
// maxKnowledgeUploadBytes 是知识库单次上传的体积上限。
|
||
// 与 media CAS 的定位一致:知识条目的媒体是引用,不该拖着一堆原始字节。
|
||
const maxKnowledgeUploadBytes = 32 << 20
|
||
|
||
// handleKnowledgeUpload 处理 multipart 上传,按实际类型分流:媒体入 CAS,
|
||
// 文本存正文。绝不把二进制当文本存。
|
||
func (h *Handler) handleKnowledgeUpload(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseMultipartForm(maxKnowledgeUploadBytes); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart: " + err.Error()})
|
||
return
|
||
}
|
||
name := strings.TrimSpace(r.FormValue("name"))
|
||
if name == "" {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||
return
|
||
}
|
||
|
||
files := r.MultipartForm.File["file"]
|
||
// 也接受通用字段名,避免前端只有 file 字段名不匹配时静默走成"无媒体"
|
||
if len(files) == 0 {
|
||
files = r.MultipartForm.File["media"]
|
||
}
|
||
if len(files) == 0 {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
|
||
return
|
||
}
|
||
if len(files) > 16 {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "最多 16 个文件"})
|
||
return
|
||
}
|
||
|
||
var (
|
||
media []sdk.KnowledgeMediaRef
|
||
texts []string
|
||
rejected []string
|
||
)
|
||
for _, fh := range files {
|
||
f, err := fh.Open()
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "open " + fh.Filename + ": " + err.Error()})
|
||
return
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(f, maxKnowledgeUploadBytes+1))
|
||
f.Close()
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read " + fh.Filename + ": " + err.Error()})
|
||
return
|
||
}
|
||
if len(data) == 0 {
|
||
rejected = append(rejected, fh.Filename+": 空文件")
|
||
continue
|
||
}
|
||
if len(data) > maxKnowledgeUploadBytes {
|
||
writeJSON(w, http.StatusRequestEntityTooLarge,
|
||
map[string]string{"error": fmt.Sprintf("%s 超过 %d 上限", fh.Filename, maxKnowledgeUploadBytes)})
|
||
return
|
||
}
|
||
|
||
mime := fh.Header.Get("Content-Type")
|
||
if mime == "" {
|
||
mime = contentTypeByExt(strings.ToLower(filepath.Ext(fh.Filename)))
|
||
}
|
||
// 按**探测到的真实类型**判定,而不是信客户端给的 Content-Type:
|
||
// 声明 text/plain 的 PNG 曾是真实场景,光看头会把二进制当文本存。
|
||
detected := http.DetectContentType(data)
|
||
if isMediaMIME(mime) || isMediaMIME(detected) {
|
||
useMIME := detected
|
||
if detected == "application/octet-stream" {
|
||
useMIME = mime
|
||
}
|
||
if h.mediaStore == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable,
|
||
map[string]string{"error": "媒体存储未初始化,无法保存图片/音视频"})
|
||
return
|
||
}
|
||
digest, err := h.mediaStore.Put(data, useMIME, "webui_knowledge")
|
||
if err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store media: " + err.Error()})
|
||
return
|
||
}
|
||
media = append(media, sdk.KnowledgeMediaRef{Digest: digest, MIME: useMIME, Kind: mediaKindOf(useMIME)})
|
||
continue
|
||
}
|
||
// 文本类:确认是合法 UTF-8 才当正文,否则拒绝并说明原因
|
||
if !utf8.Valid(data) {
|
||
rejected = append(rejected, fmt.Sprintf("%s: 非文本内容且无法识别为媒体(type=%q)", fh.Filename, useMIMEOr(detected, mime)))
|
||
continue
|
||
}
|
||
texts = append(texts, string(data))
|
||
}
|
||
|
||
content := strings.TrimSpace(r.FormValue("content"))
|
||
if content == "" {
|
||
content = strings.Join(texts, "\n\n")
|
||
}
|
||
if strings.TrimSpace(content) == "" && len(media) == 0 {
|
||
msg := "没有可写入的内容"
|
||
if len(rejected) > 0 {
|
||
msg += ":" + strings.Join(rejected, "; ")
|
||
}
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
|
||
return
|
||
}
|
||
|
||
if err := h.knowledge.AddWithMedia(name, content, media); err != nil {
|
||
code, msg := knowledgeStatusFor(err)
|
||
writeJSON(w, code, map[string]string{"error": msg})
|
||
return
|
||
}
|
||
resp := map[string]interface{}{"status": "created", "name": name, "media": media}
|
||
if len(rejected) > 0 {
|
||
resp["rejected"] = rejected
|
||
}
|
||
writeJSON(w, http.StatusCreated, resp)
|
||
}
|
||
|
||
func useMIMEOr(vals ...string) string {
|
||
for _, v := range vals {
|
||
if v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isMediaMIME(m string) bool {
|
||
return strings.HasPrefix(m, "image/") || strings.HasPrefix(m, "audio/") || strings.HasPrefix(m, "video/")
|
||
}
|
||
|
||
func mediaKindOf(m string) string {
|
||
switch {
|
||
case strings.HasPrefix(m, "image/"):
|
||
return "image"
|
||
case strings.HasPrefix(m, "audio/"):
|
||
return "audio"
|
||
case strings.HasPrefix(m, "video/"):
|
||
return "video"
|
||
}
|
||
return "file"
|
||
}
|
||
|
||
func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) {
|
||
if h.textMem == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "text memory not available"})
|
||
return
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
recent, _ := h.textMem.RecentEvents(50)
|
||
stats := h.textMem.Stats()
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"stats": stats,
|
||
"recent": recent,
|
||
})
|
||
case http.MethodDelete:
|
||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"})
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleAdapters(w http.ResponseWriter, r *http.Request) {
|
||
if h.adapter == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"})
|
||
return
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": h.adapter.List()})
|
||
case http.MethodPost:
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
Code string `json:"code"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||
return
|
||
}
|
||
if err := h.adapter.Load(req.Name, req.Code); err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]string{"status": "loaded", "name": req.Name})
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleAdapterByID(w http.ResponseWriter, r *http.Request) {
|
||
if h.adapter == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "lua vm not available"})
|
||
return
|
||
}
|
||
name := strings.TrimPrefix(r.URL.Path, "/api/v1/adapters/")
|
||
if name == "" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
for _, a := range h.adapter.List() {
|
||
if a.Name == name {
|
||
writeJSON(w, http.StatusOK, a)
|
||
return
|
||
}
|
||
}
|
||
http.NotFound(w, r)
|
||
case http.MethodDelete:
|
||
if err := h.adapter.Remove(name); err != nil {
|
||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "adapter not found"})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
|
||
default:
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
}
|
||
|
||
func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"network_status": "monitoring",
|
||
"endpoints": h.config.Get().Defaults.LLMEndpoints,
|
||
})
|
||
}
|
||
|
||
func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) {
|
||
if h.tracker == nil {
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "tracker not available"})
|
||
return
|
||
}
|
||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tracker")
|
||
path = strings.TrimPrefix(path, "/")
|
||
|
||
switch {
|
||
case path == "changesets" && r.Method == http.MethodGet:
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"changesets": h.tracker.ChangeSets(),
|
||
"count": len(h.tracker.ChangeSets()),
|
||
})
|
||
case path == "rollback" && r.Method == http.MethodPost:
|
||
if err := h.tracker.Rollback(); err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "rollback_complete"})
|
||
case path == "" && r.Method == http.MethodGet:
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"stats": h.tracker.Stats(),
|
||
"has_changes": h.tracker.HasChanges(),
|
||
"changesets": len(h.tracker.ChangeSets()),
|
||
})
|
||
case path == "" && r.Method == http.MethodDelete:
|
||
if err := h.tracker.Rollback(); err != nil {
|
||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||
default:
|
||
http.Error(w, "not found", http.StatusNotFound)
|
||
}
|
||
}
|