Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ COPY --from=frontend-builder /src/frontend/dist ./frontend/dist
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOMAXPROCS=1 \
go build -trimpath -ldflags="-s -w" -o /out/tracker .

# Keep Node.js 22 in the final image: the Go service starts the restricted
# Harness JSON-RPC agent only when STUDY_HARNESS_ENABLED=true.
# Keep Node.js 22 in the final image: the Go service always uses the restricted
# Harness JSON-RPC agent for AI chat.
FROM docker.m.daocloud.io/library/node:22-bookworm-slim
WORKDIR /app
# The Go builder already contains the certificate bundle. Copy it instead of
Expand Down
107 changes: 4 additions & 103 deletions api/handlers/ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ package handlers
import (
"errors"
"net/http"
"strings"

"github.com/gin-gonic/gin"

models "study-tracker-go/internal/model"
"study-tracker-go/internal/service"
)

// AIChat accepts a user question and keeps the provider key on the server.
// The browser receives only the model answer and titles of the context used.
// AIChat accepts a user question for the Harness-only learning assistant. The
// browser receives the answer and citations, never the provider key or tool
// capability token.
func AIChat(c *gin.Context) {
var request models.AIChatRequest
if err := c.ShouldBindJSON(&request); err != nil {
Expand All @@ -24,8 +24,6 @@ func AIChat(c *gin.Context) {
switch {
case errors.Is(err, service.ErrDeepSeekNotConfigured):
respondProblem(c, http.StatusBadRequest, "deepseek_not_configured", err.Error())
case errors.Is(err, service.ErrDeepSeekClientUnavailable):
respondProblem(c, http.StatusServiceUnavailable, "deepseek_client_unavailable", err.Error())
case errors.Is(err, service.ErrHarnessRuntimeUnavailable):
respondProblem(c, http.StatusServiceUnavailable, "harness_runtime_unavailable", err.Error())
case errors.Is(err, service.ErrAIInvalidScope):
Expand All @@ -38,103 +36,6 @@ func AIChat(c *gin.Context) {
c.JSON(http.StatusOK, response)
}

// PreviewAIEdit prepares a change set for one explicitly selected note. It is
// deliberately separate from apply so the browser can always show a preview.
func PreviewAIEdit(c *gin.Context) {
var request models.AIEditPreviewRequest
if err := c.ShouldBindJSON(&request); err != nil {
respondProblem(c, http.StatusBadRequest, "invalid_ai_edit_request", "AI 编辑请求格式错误")
return
}
response, err := service.PreviewAIEdit(c.Request.Context(), request)
if err != nil {
switch {
case errors.Is(err, service.ErrDeepSeekNotConfigured):
respondProblem(c, http.StatusBadRequest, "deepseek_not_configured", err.Error())
case errors.Is(err, service.ErrDeepSeekClientUnavailable):
respondProblem(c, http.StatusServiceUnavailable, "deepseek_client_unavailable", err.Error())
case errors.Is(err, service.ErrAIEditTarget):
respondProblem(c, http.StatusBadRequest, "invalid_ai_edit_target", err.Error())
default:
respondError(c, http.StatusBadGateway, err)
}
return
}
c.JSON(http.StatusOK, response)
}

// ApplyAIEdit persists a preview only after the user explicitly confirms it.
func ApplyAIEdit(c *gin.Context) {
var request models.AIEditApplyRequest
if err := c.ShouldBindJSON(&request); err != nil {
respondProblem(c, http.StatusBadRequest, "invalid_ai_edit_apply", "AI 编辑确认格式错误")
return
}
item, err := service.ApplyAIEdit(c.Request.Context(), request)
if err != nil {
switch {
case errors.Is(err, service.ErrAIEditTarget):
respondProblem(c, http.StatusBadRequest, "invalid_ai_edit_target", err.Error())
case strings.Contains(err.Error(), "版本冲突"):
respondProblem(c, http.StatusConflict, "version_conflict", "笔记已被更新,请重新生成修改预览")
default:
respondError(c, http.StatusBadRequest, err)
}
return
}
c.JSON(http.StatusOK, item)
}

// PreviewAINoteWrite resolves a natural-language target path and generates a
// create or update preview without changing the user's library.
func PreviewAINoteWrite(c *gin.Context) {
var request models.AINoteWritePreviewRequest
if err := c.ShouldBindJSON(&request); err != nil {
respondProblem(c, http.StatusBadRequest, "invalid_ai_note_write_request", "AI 写入请求格式错误")
return
}
response, err := service.PreviewAINoteWrite(c.Request.Context(), request)
if err != nil {
switch {
case errors.Is(err, service.ErrDeepSeekNotConfigured):
respondProblem(c, http.StatusBadRequest, "deepseek_not_configured", err.Error())
case errors.Is(err, service.ErrDeepSeekClientUnavailable):
respondProblem(c, http.StatusServiceUnavailable, "deepseek_client_unavailable", err.Error())
case errors.Is(err, service.ErrAINoteWriteIntent):
respondProblem(c, http.StatusBadRequest, "not_ai_note_write_intent", err.Error())
case errors.Is(err, service.ErrAINoteWriteTarget):
respondProblem(c, http.StatusBadRequest, "invalid_ai_note_write_target", err.Error())
default:
respondError(c, http.StatusBadGateway, err)
}
return
}
c.JSON(http.StatusOK, response)
}

// ApplyAINoteWrite creates or updates exactly the note shown in an approved
// preview. It is deliberately separate from preview generation.
func ApplyAINoteWrite(c *gin.Context) {
var request models.AINoteWriteApplyRequest
if err := c.ShouldBindJSON(&request); err != nil {
respondProblem(c, http.StatusBadRequest, "invalid_ai_note_write_apply", "AI 写入确认格式错误")
return
}
item, err := service.ApplyAINoteWrite(c.Request.Context(), request)
if err != nil {
switch {
case errors.Is(err, service.ErrAINoteWriteConflict), strings.Contains(err.Error(), "版本冲突"):
respondProblem(c, http.StatusConflict, "ai_note_write_conflict", "目标笔记已变化,请重新生成写入预览")
case errors.Is(err, service.ErrAINoteWriteTarget):
respondProblem(c, http.StatusBadRequest, "invalid_ai_note_write_target", err.Error())
default:
respondError(c, http.StatusBadRequest, err)
}
return
}
c.JSON(http.StatusOK, item)
}

type aiConversationRequest struct {
Conversations []models.AIConversation `json:"conversations"`
}
Expand All @@ -150,7 +51,7 @@ func GetAIConversation(c *gin.Context) {
}

// SaveAIConversation persists a bounded collection of user/assistant chats.
// API keys and provider prompts are never included in this payload.
// The Harness session identifier is preserved without exposing any tool grant.
func SaveAIConversation(c *gin.Context) {
var request aiConversationRequest
if err := c.ShouldBindJSON(&request); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions api/handlers/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ func HarnessTool(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": result})
}

// HarnessStatus lets the browser select the agent workflow only when its
// local runtime has been installed and explicitly enabled by the operator.
// HarnessStatus lets the browser enable AI chat only when the required local
// Harness runtime is installed and ready.
func HarnessStatus(c *gin.Context) {
c.JSON(http.StatusOK, service.HarnessRuntimeStatus(c.Request.Context()))
}
Loading
Loading