Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

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)
}}
}