f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
239 lines
6.6 KiB
Go
239 lines
6.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"go.kenn.io/agentsview/internal/importer"
|
|
)
|
|
|
|
func (s *Server) registerImportRoutes() {
|
|
group := newRouteGroup(s.api, "/api/v1/import", "Import")
|
|
|
|
stream(
|
|
s, group, http.MethodPost, "/claude-ai",
|
|
"Import Claude.ai archive", s.humaImportClaudeAI,
|
|
streamJSONResponse(),
|
|
)
|
|
stream(
|
|
s, group, http.MethodPost, "/chatgpt",
|
|
"Import ChatGPT archive", s.humaImportChatGPT,
|
|
streamJSONResponse(),
|
|
)
|
|
}
|
|
|
|
type importArchiveInput struct {
|
|
Accept string `header:"Accept" doc:"Use text/event-stream to stream progress"`
|
|
RawBody huma.MultipartFormFiles[importArchiveForm]
|
|
}
|
|
|
|
type importArchiveForm struct {
|
|
File huma.FormFile `form:"file" contentType:"application/octet-stream" required:"true"`
|
|
}
|
|
|
|
func (s *Server) humaImportClaudeAI(
|
|
ctx context.Context,
|
|
in *importArchiveInput,
|
|
) (*huma.StreamResponse, error) {
|
|
if s.db.ReadOnly() {
|
|
return nil, apiError(http.StatusNotImplemented,
|
|
"import not available in read-only mode")
|
|
}
|
|
file := in.RawBody.Data().File
|
|
if !file.IsSet {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"missing 'file' field in form data")
|
|
}
|
|
if !strings.Contains(in.Accept, "text/event-stream") {
|
|
stats, err := s.importClaudeAIFromFile(ctx, file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return jsonStreamResponse(stats), nil
|
|
}
|
|
return &huma.StreamResponse{Body: func(hctx huma.Context) {
|
|
stream, ok := newHumaSSEStream(hctx)
|
|
if !ok {
|
|
writeHumaJSON(hctx, http.StatusInternalServerError,
|
|
apiErrorResponse{Message: "streaming not supported"})
|
|
return
|
|
}
|
|
stats, err := s.importClaudeAIFromFileWithCallbacks(hctx.Context(), file, &importer.ImportCallbacks{
|
|
OnProgress: func(stats importer.ImportStats) {
|
|
stream.SendJSON("progress", stats)
|
|
},
|
|
OnIndexing: func() {
|
|
stream.SendJSON("indexing", struct{}{})
|
|
},
|
|
})
|
|
if err != nil {
|
|
stream.SendJSON("error", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
stream.SendJSON("done", stats)
|
|
}}, nil
|
|
}
|
|
|
|
func (s *Server) importClaudeAIFromFile(
|
|
ctx context.Context,
|
|
file huma.FormFile,
|
|
) (importer.ImportStats, error) {
|
|
return s.importClaudeAIFromFileWithCallbacks(ctx, file, nil)
|
|
}
|
|
|
|
func (s *Server) importClaudeAIFromFileWithCallbacks(
|
|
ctx context.Context,
|
|
file huma.FormFile,
|
|
cb *importer.ImportCallbacks,
|
|
) (importer.ImportStats, error) {
|
|
reader, cleanup, err := claudeImportReader(file)
|
|
if err != nil {
|
|
return importer.ImportStats{}, err
|
|
}
|
|
defer cleanup()
|
|
stats, err := importer.ImportClaudeAI(ctx, s.db, reader, cb)
|
|
if err != nil {
|
|
return importer.ImportStats{}, apiError(http.StatusInternalServerError,
|
|
"import failed: "+err.Error())
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func claudeImportReader(file huma.FormFile) (io.Reader, func(), error) {
|
|
cleanup := func() {}
|
|
reader := io.Reader(file)
|
|
if strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
|
|
tmpFile, tmpErr := os.CreateTemp("", "claude-import-*.zip")
|
|
if tmpErr != nil {
|
|
return nil, cleanup, apiError(http.StatusInternalServerError,
|
|
"failed to create temp file")
|
|
}
|
|
tmpName := tmpFile.Name()
|
|
cleanup = func() { _ = os.Remove(tmpName) }
|
|
if _, tmpErr = io.Copy(tmpFile, file); tmpErr != nil {
|
|
_ = tmpFile.Close()
|
|
cleanup()
|
|
return nil, func() {}, apiError(http.StatusInternalServerError,
|
|
"failed to save upload")
|
|
}
|
|
_ = tmpFile.Close()
|
|
dir, zipCleanup, extractErr := importer.ExtractZip(tmpName)
|
|
if extractErr != nil {
|
|
cleanup()
|
|
return nil, func() {}, apiError(http.StatusBadRequest,
|
|
"failed to extract zip: "+extractErr.Error())
|
|
}
|
|
cleanup = func() {
|
|
zipCleanup()
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
jsonPath := filepath.Join(dir, "conversations.json")
|
|
jsonFile, openErr := os.Open(jsonPath)
|
|
if openErr != nil {
|
|
cleanup()
|
|
return nil, func() {}, apiError(http.StatusBadRequest,
|
|
"no conversations.json found in zip")
|
|
}
|
|
oldCleanup := cleanup
|
|
cleanup = func() {
|
|
_ = jsonFile.Close()
|
|
oldCleanup()
|
|
}
|
|
reader = jsonFile
|
|
}
|
|
return reader, cleanup, nil
|
|
}
|
|
|
|
func (s *Server) humaImportChatGPT(
|
|
ctx context.Context,
|
|
in *importArchiveInput,
|
|
) (*huma.StreamResponse, error) {
|
|
if s.db.ReadOnly() {
|
|
return nil, apiError(http.StatusNotImplemented,
|
|
"import not available in read-only mode")
|
|
}
|
|
file := in.RawBody.Data().File
|
|
if !file.IsSet {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"missing 'file' field in form data")
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
|
|
return nil, apiError(http.StatusBadRequest,
|
|
"ChatGPT import requires a .zip file")
|
|
}
|
|
if !strings.Contains(in.Accept, "text/event-stream") {
|
|
stats, err := s.importChatGPTFromFile(ctx, file, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return jsonStreamResponse(stats), nil
|
|
}
|
|
return &huma.StreamResponse{Body: func(hctx huma.Context) {
|
|
stream, ok := newHumaSSEStream(hctx)
|
|
if !ok {
|
|
writeHumaJSON(hctx, http.StatusInternalServerError,
|
|
apiErrorResponse{Message: "streaming not supported"})
|
|
return
|
|
}
|
|
stats, err := s.importChatGPTFromFile(hctx.Context(), file, &importer.ImportCallbacks{
|
|
OnProgress: func(stats importer.ImportStats) {
|
|
stream.SendJSON("progress", stats)
|
|
},
|
|
OnIndexing: func() {
|
|
stream.SendJSON("indexing", struct{}{})
|
|
},
|
|
})
|
|
if err != nil {
|
|
stream.SendJSON("error", map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
stream.SendJSON("done", stats)
|
|
}}, nil
|
|
}
|
|
|
|
func (s *Server) importChatGPTFromFile(
|
|
ctx context.Context,
|
|
file huma.FormFile,
|
|
cb *importer.ImportCallbacks,
|
|
) (importer.ImportStats, error) {
|
|
tmpFile, err := os.CreateTemp("", "chatgpt-import-*.zip")
|
|
if err != nil {
|
|
return importer.ImportStats{}, apiError(http.StatusInternalServerError,
|
|
"failed to create temp file")
|
|
}
|
|
tmpName := tmpFile.Name()
|
|
defer os.Remove(tmpName)
|
|
if _, err = io.Copy(tmpFile, file); err != nil {
|
|
_ = tmpFile.Close()
|
|
return importer.ImportStats{}, apiError(http.StatusInternalServerError,
|
|
"failed to save upload")
|
|
}
|
|
_ = tmpFile.Close()
|
|
dir, cleanup, err := importer.ExtractZip(tmpName)
|
|
if err != nil {
|
|
return importer.ImportStats{}, apiError(http.StatusBadRequest,
|
|
"failed to extract zip: "+err.Error())
|
|
}
|
|
defer cleanup()
|
|
stats, err := importer.ImportChatGPT(ctx, s.db, dir,
|
|
filepath.Join(s.cfg.DataDir, "assets"), cb)
|
|
if err != nil {
|
|
return importer.ImportStats{}, apiError(http.StatusInternalServerError,
|
|
"import failed: "+err.Error())
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func jsonStreamResponse(value any) *huma.StreamResponse {
|
|
return &huma.StreamResponse{Body: func(hctx huma.Context) {
|
|
hctx.SetHeader("Content-Type", "application/json")
|
|
_ = json.NewEncoder(hctx.BodyWriter()).Encode(value)
|
|
}}
|
|
}
|