+ diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index b483ce6..b072b0b 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -79,6 +79,31 @@ jobs: --clobber env: GH_TOKEN: ${{ github.token }} + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: tanq16 + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: tanq16/ai-context:main publish: needs: [process-commit, build] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d5e5643 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:alpine AS builder +WORKDIR /app +COPY . . +RUN go build -ldflags="-s -w" -o ai-context . + +FROM alpine:latest +WORKDIR /app +RUN mkdir -p /app/context +COPY --from=builder /app/ai-context . +EXPOSE 8080 +CMD ["/app/ai-context"] diff --git a/aicontext/handler.go b/aicontext/handler.go index 89d9160..6cce8ef 100644 --- a/aicontext/handler.go +++ b/aicontext/handler.go @@ -36,7 +36,6 @@ func GetOutFileName(input string) string { res := strings.ToLower(re.ReplaceAllString(input, "_")) res = reReplace.ReplaceAllString(res, "") res = strings.Trim(res, "_") - // return res + "-" + time.Now().Format("150405") + ".md" return res + ".md" } @@ -44,14 +43,26 @@ func cleanURL(rawURL string) (string, error) { if after, ok := strings.CutPrefix(rawURL, "github/"); ok { rawURL = "https://github.com/" + after } - if match, _ := regexp.MatchString(URLRegex["dir"], rawURL); match { + if match, _ := regexp.MatchString(`^\.?\.?\/.*`, rawURL); match { return rawURL, nil } parsedURL, err := url.Parse(rawURL) if err != nil { return "", fmt.Errorf("failed to parse url: %w", err) } - parsedURL.RawQuery = "" + // preserve video id for yt + if strings.Contains(parsedURL.Host, "youtube.com") { + videoID := parsedURL.Query().Get("v") + if videoID != "" { + query := url.Values{} + query.Set("v", videoID) + parsedURL.RawQuery = query.Encode() + } else { + parsedURL.RawQuery = "" + } + } else { + parsedURL.RawQuery = "" + } parsedURL.Fragment = "" return parsedURL.String(), nil } diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..a31d0c6 --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/tanq16/ai-context/aicontext" +) + +//go:embed all:web +var webFS embed.FS + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Launch a web server to use the AI Context tool through a UI.", + Run: runServer, +} + +type generateRequest struct { + URL string `json:"url"` + Ignore []string `json:"ignore"` +} + +type generateResponse struct { + Content string `json:"content"` +} + +func runServer(cmd *cobra.Command, args []string) { + staticFS, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatalf("Failed to create static file system: %v", err) + } + + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + http.HandleFunc("/generate", generateHandler) + http.HandleFunc("/load", loadHandler) + http.HandleFunc("/clear", clearHandler) // Add the new /clear route + http.HandleFunc("/", rootHandler(staticFS)) + + port := "8080" + fmt.Printf("Starting server at http://localhost:%s\n", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +func rootHandler(fs fs.FS) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.FileServer(http.FS(fs)).ServeHTTP(w, r) + return + } + indexHTML, err := webFS.ReadFile("web/index.html") + if err != nil { + http.Error(w, "Could not read index.html", http.StatusInternalServerError) + log.Printf("Error reading embedded index.html: %v", err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) + } +} + +// clearHandler handles the request to delete the generated context file. +func clearHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed) + return + } + + if err := cleanupContextDir(); err != nil { + log.Printf("Error during cleanup: %v", err) + http.Error(w, "Failed to clear context file", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) // Success, no content to return +} + +func loadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Only GET method is allowed", http.StatusMethodNotAllowed) + return + } + + outputFile, err := findGeneratedFile() + if err != nil { + json.NewEncoder(w).Encode(generateResponse{Content: ""}) + return + } + + content, err := os.ReadFile(outputFile) + if err != nil { + http.Error(w, "Failed to read context file", http.StatusInternalServerError) + log.Printf("Error reading output file %s: %v", outputFile, err) + return + } + + resp := generateResponse{Content: string(content)} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func generateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed) + return + } + + var req generateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + if req.URL == "" { + http.Error(w, "URL is required", http.StatusBadRequest) + return + } + + if err := cleanupContextDir(); err != nil { + log.Printf("Warning: could not clean up context directory: %v", err) + } + + aicontext.Handler([]string{req.URL}, req.Ignore, 1) + + outputFile, err := findGeneratedFile() + if err != nil { + http.Error(w, "Failed to find generated context file", http.StatusInternalServerError) + log.Printf("Error finding generated file: %v", err) + return + } + + content, err := os.ReadFile(outputFile) + if err != nil { + http.Error(w, "Failed to read context file", http.StatusInternalServerError) + log.Printf("Error reading output file %s: %v", outputFile, err) + return + } + + resp := generateResponse{Content: string(content)} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error encoding response: %v", err) + } +} + +func cleanupContextDir() error { + dir := "context" + files, err := filepath.Glob(filepath.Join(dir, "*.md")) + if err != nil { + return err + } + for _, file := range files { + if err := os.Remove(file); err != nil { + log.Printf("Failed to remove file %s: %v", file, err) + } + } + return nil +} + +func findGeneratedFile() (string, error) { + dir := "context" + files, err := filepath.Glob(filepath.Join(dir, "*.md")) + if err != nil { + return "", fmt.Errorf("error searching for files: %w", err) + } + if len(files) == 0 { + return "", fmt.Errorf("no markdown file found in context directory") + } + return files[0], nil +} + +func init() { + rootCmd.AddCommand(serveCmd) +} diff --git a/cmd/web/asset-download.sh b/cmd/web/asset-download.sh new file mode 100644 index 0000000..61ed49b --- /dev/null +++ b/cmd/web/asset-download.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +mkdir -p static/css +mkdir -p static/js +mkdir -p static/webfonts +mkdir -p static/fonts + +echo "Downloading assets..." + +# Download Tailwind CSS +curl -sL "https://cdn.tailwindcss.com" -o "static/js/tailwindcss.js" + +# Download Font Awesome CSS and its webfonts +curl -sL "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" -o "static/css/all.min.css" +curl -sL "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-brands-400.woff2" -o "static/webfonts/fa-brands-400.woff2" +curl -sL "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-regular-400.woff2" -o "static/webfonts/fa-regular-400.woff2" +curl -sL "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-solid-900.woff2" -o "static/webfonts/fa-solid-900.woff2" +curl -sL "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/fa-v4compatibility.woff2" -o "static/webfonts/fa-v4compatibility.woff2" + +# Update Font Awesome CSS to use local webfonts path +sed -i.bak 's|https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/webfonts/|/static/webfonts/|g' static/css/all.min.css +rm static/css/all.min.css.bak + +# Download Inter font CSS from Google Fonts +curl -sL "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" -A "Mozilla/5.0" -o "static/css/inter.css" + +# Download font files referenced in the CSS +grep -o 'https://fonts.gstatic.com/s/inter/[^)]*' static/css/inter.css | while read -r url; do + # Remove the single quote from the end + clean_url=$(echo "$url" | sed "s/'$//") + filename=$(basename "$clean_url") + curl -sL "$clean_url" -o "static/fonts/$filename" +done + +# Update font CSS to use local font files +sed -i.bak 's|https://fonts.gstatic.com/s/inter/v[0-9]*/|/static/fonts/|g' static/css/inter.css +rm static/css/inter.css.bak + +echo "All assets downloaded successfully!" diff --git a/cmd/web/index.html b/cmd/web/index.html new file mode 100644 index 0000000..c71ba5e --- /dev/null +++ b/cmd/web/index.html @@ -0,0 +1,247 @@ + + +
+ + +
+
+ h?l[c][f]=s+1:n.charAt(c-1)===i.charAt(f-1)?l[c][f]=l[c-1][f-1]:l[c][f]=Math.min(l[c-1][f-1]+1,Math.min(l[c][f-1]+1,l[c-1][f]+1)),l[c][f]{u();function Xg(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Zg(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Zg(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=Xg(r[i],e)+t;return t}return Xg(r,e)}Jg.exports=Zg});var ry=x((lq,ty)=>{u();var Cs="-".charCodeAt(0),_s="+".charCodeAt(0),Fl=".".charCodeAt(0),j2="e".charCodeAt(0),z2="E".charCodeAt(0);function U2(r){var e=r.charCodeAt(0),t;if(e===_s||e===Cs){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===Fl&&i>=48&&i<=57}return e===Fl?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}ty.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!U2(r))return!1;for(i=r.charCodeAt(e),(i===_s||i===Cs)&&e++;e{u();function Gy(r,e){var t=r.type,i=r.value,n,s;return e&&(s=e(r))!==void 0?s:t==="word"||t==="space"?i:t==="string"?(n=r.quote||"",n+i+(r.unclosed?"":n)):t==="comment"?"/*"+i+(r.unclosed?"":"*/"):t==="div"?(r.before||"")+i+(r.after||""):Array.isArray(r.nodes)?(n=Qy(r.nodes,e),t!=="function"?n:i+"("+(r.before||"")+n+(r.after||"")+(r.unclosed?"":")")):i}function Qy(r,e){var t,i;if(Array.isArray(r)){for(t="",i=r.length-1;~i;i-=1)t=Gy(r[i],e)+t;return t}return Gy(r,e)}Yy.exports=Qy});var Zy=x((o$,Xy)=>{u();var $s="-".charCodeAt(0),Ls="+".charCodeAt(0),su=".".charCodeAt(0),vO="e".charCodeAt(0),xO="E".charCodeAt(0);function kO(r){var e=r.charCodeAt(0),t;if(e===Ls||e===$s){if(t=r.charCodeAt(1),t>=48&&t<=57)return!0;var i=r.charCodeAt(2);return t===su&&i>=48&&i<=57}return e===su?(t=r.charCodeAt(1),t>=48&&t<=57):e>=48&&e<=57}Xy.exports=function(r){var e=0,t=r.length,i,n,s;if(t===0||!kO(r))return!1;for(i=r.charCodeAt(e),(i===Ls||i===$s)&&e++;e