@aigne/doc-smith 0.8.13-beta → 0.8.14-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.14-beta](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.13...v0.8.14-beta) (2025-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * **file-utils:** add comprehensive file type detection and image upload functionality ([#200](https://github.com/AIGNE-io/aigne-doc-smith/issues/200)) ([ab7aae3](https://github.com/AIGNE-io/aigne-doc-smith/commit/ab7aae33ed9b5209e824ba91fbf9c94d37187d83))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * use `output` field present tool result to enhance LLM understanding ([#202](https://github.com/AIGNE-io/aigne-doc-smith/issues/202)) ([511178d](https://github.com/AIGNE-io/aigne-doc-smith/commit/511178de7a88fdecf2136f90035d87957e23b23f))
14
+
15
+ ## [0.8.13](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.13-beta...v0.8.13) (2025-10-16)
16
+
17
+
18
+ ### Miscellaneous Chores
19
+
20
+ * release 0.8.13 ([496e30f](https://github.com/AIGNE-io/aigne-doc-smith/commit/496e30f60d7e78602ffdc0fc070bf09ce4415fde))
21
+
3
22
  ## [0.8.13-beta](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.12...v0.8.13-beta) (2025-10-16)
4
23
 
5
24
 
@@ -1,8 +1,8 @@
1
1
  import { checkContent } from "../../utils/d2-utils.mjs";
2
2
 
3
- export default async function checkD2DiagramIsValid({ diagramSourceCode }) {
3
+ export default async function checkD2DiagramIsValid({ output }) {
4
4
  try {
5
- await checkContent({ content: diagramSourceCode });
5
+ await checkContent({ content: output });
6
6
  return {
7
7
  isValid: true,
8
8
  };
@@ -17,12 +17,12 @@ export default async function checkD2DiagramIsValid({ diagramSourceCode }) {
17
17
  checkD2DiagramIsValid.input_schema = {
18
18
  type: "object",
19
19
  properties: {
20
- diagramSourceCode: {
20
+ output: {
21
21
  type: "string",
22
22
  description: "Source code of d2 diagram",
23
23
  },
24
24
  },
25
- required: ["diagramSourceCode"],
25
+ required: ["output"],
26
26
  };
27
27
  checkD2DiagramIsValid.output_schema = {
28
28
  type: "object",
@@ -4,11 +4,11 @@ export default async function wrapDiagramCode({ diagramSourceCode }) {
4
4
  try {
5
5
  const result = await wrapCode({ content: diagramSourceCode });
6
6
  return {
7
- diagramSourceCode: result,
7
+ output: result,
8
8
  };
9
9
  } catch {
10
10
  return {
11
- diagramSourceCode,
11
+ output: diagramSourceCode,
12
12
  };
13
13
  }
14
14
  }
@@ -26,10 +26,10 @@ wrapDiagramCode.input_schema = {
26
26
  wrapDiagramCode.output_schema = {
27
27
  type: "object",
28
28
  properties: {
29
- diagramSourceCode: {
29
+ output: {
30
30
  type: "string",
31
31
  description: "Source code of d2 diagram",
32
32
  },
33
33
  },
34
- required: ["diagramSourceCode"],
34
+ required: ["output"],
35
35
  };
@@ -1,9 +1,8 @@
1
- import { basename, extname, join } from "node:path";
1
+ import { basename, join } from "node:path";
2
2
  import { publishDocs as publishDocsFn } from "@aigne/publish-docs";
3
3
  import { BrokerClient } from "@blocklet/payment-broker-client/node";
4
4
  import chalk from "chalk";
5
5
  import fs from "fs-extra";
6
- import slugify from "slugify";
7
6
 
8
7
  import { getAccessToken, getOfficialAccessToken } from "../../utils/auth-utils.mjs";
9
8
  import {
@@ -15,8 +14,6 @@ import {
15
14
  } from "../../utils/constants/index.mjs";
16
15
  import { beforePublishHook, ensureTmpDir } from "../../utils/d2-utils.mjs";
17
16
  import { deploy } from "../../utils/deploy.mjs";
18
- import { getExtnameFromContentType } from "../../utils/file-utils.mjs";
19
- import { uploadFiles } from "../../utils/upload-files.mjs";
20
17
  import {
21
18
  getGithubRepoUrl,
22
19
  isHttp,
@@ -24,6 +21,7 @@ import {
24
21
  saveValueToConfig,
25
22
  } from "../../utils/utils.mjs";
26
23
  import updateBranding from "../utils/update-branding.mjs";
24
+ import { downloadAndUploadImage } from "../../utils/file-utils.mjs";
27
25
 
28
26
  const BASE_URL = process.env.DOC_SMITH_BASE_URL || CLOUD_SERVICE_URL_PROD;
29
27
 
@@ -175,46 +173,22 @@ export default async function publishDocs(
175
173
  description: projectDesc || config?.projectDesc || "",
176
174
  icon: projectLogo || config?.projectLogo || "",
177
175
  };
176
+ let finalPath = null;
178
177
 
179
178
  // Handle project logo download if it's a URL
180
179
  if (projectInfo.icon && isHttp(projectInfo.icon)) {
181
- const tempFilePath = join(docsDir, slugify(basename(projectInfo.icon)));
182
- const initialExt = extname(projectInfo.icon);
183
- let ext = initialExt;
184
-
185
- try {
186
- const response = await fetch(projectInfo.icon);
187
- const blob = await response.blob();
188
- const arrayBuffer = await blob.arrayBuffer();
189
- const buffer = Buffer.from(arrayBuffer);
190
-
191
- if (response.ok) {
192
- if (!ext) {
193
- const contentType = response.headers.get("content-type");
194
- ext = getExtnameFromContentType(contentType);
195
- }
196
-
197
- const finalPath = ext ? `${tempFilePath}.${ext}` : tempFilePath;
198
- fs.writeFileSync(finalPath, buffer);
199
-
200
- const filePath = join(process.cwd(), finalPath);
201
- const { results: uploadResults } = await uploadFiles({
202
- appUrl,
203
- filePaths: [filePath],
204
- accessToken,
205
- concurrency: 1,
206
- });
207
-
208
- // FIXME: 暂时保持使用绝对路径,需要修改为相对路径 @pengfei
209
- projectInfo.icon = uploadResults?.[0]?.url || projectInfo.icon;
210
- }
211
- } catch (error) {
212
- console.warn(`Failed to download project logo from ${projectInfo.icon}: ${error.message}`);
213
- }
180
+ const { url: uploadedImageUrl, downloadFinalPath } = await downloadAndUploadImage(
181
+ projectInfo.icon,
182
+ docsDir,
183
+ appUrl,
184
+ accessToken,
185
+ );
186
+ projectInfo.icon = uploadedImageUrl;
187
+ finalPath = downloadFinalPath;
214
188
  }
215
189
 
216
190
  if (shouldWithBranding) {
217
- updateBranding({ appUrl, projectInfo, accessToken });
191
+ updateBranding({ appUrl, projectInfo, accessToken, finalPath });
218
192
  }
219
193
 
220
194
  // Construct boardMeta object
@@ -1,5 +1,4 @@
1
1
  import { stat } from "node:fs/promises";
2
- import { resolve } from "node:path";
3
2
  import chalk from "chalk";
4
3
  import { joinURL } from "ufo";
5
4
 
@@ -8,12 +7,11 @@ import {
8
7
  CLOUD_SERVICE_URL_PROD,
9
8
  CLOUD_SERVICE_URL_STAGING,
10
9
  DISCUSS_KIT_DID,
11
- DOC_SMITH_DIR,
12
10
  } from "../../utils/constants/index.mjs";
13
11
  import { requestWithAuthToken } from "../../utils/request.mjs";
14
12
  import { uploadFiles } from "../../utils/upload-files.mjs";
15
13
 
16
- export default async function updateBranding({ appUrl, projectInfo, accessToken }) {
14
+ export default async function updateBranding({ appUrl, projectInfo, accessToken, finalPath }) {
17
15
  try {
18
16
  const origin = new URL(appUrl).origin;
19
17
  if ([CLOUD_SERVICE_URL_PROD, CLOUD_SERVICE_URL_STAGING].includes(origin)) {
@@ -43,15 +41,19 @@ export default async function updateBranding({ appUrl, projectInfo, accessToken
43
41
  );
44
42
 
45
43
  if (res.success) {
44
+ if (!finalPath) {
45
+ console.warn("\n🔄 Skipped updating branding for missing logo file\n");
46
+ return;
47
+ }
48
+
46
49
  try {
47
- const projectLogoPath = resolve(process.cwd(), DOC_SMITH_DIR, projectInfo.icon);
48
- const projectLogoStat = await stat(projectLogoPath);
50
+ const projectLogoStat = await stat(finalPath);
49
51
 
50
52
  if (projectLogoStat.isFile()) {
51
53
  // Upload to blocklet logo endpoint
52
54
  await uploadFiles({
53
55
  appUrl: origin,
54
- filePaths: [projectLogoPath],
56
+ filePaths: [finalPath],
55
57
  accessToken,
56
58
  concurrency: 1,
57
59
  endpoint: `${origin}/.well-known/service/blocklet/logo/upload/square/${componentInfo.did}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aigne/doc-smith",
3
- "version": "0.8.13-beta",
3
+ "version": "0.8.14-beta",
4
4
  "description": "AI-driven documentation generation tool built on the AIGNE Framework",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -38,6 +38,7 @@
38
38
  "debug": "^4.4.1",
39
39
  "diff": "^8.0.2",
40
40
  "dompurify": "^3.2.6",
41
+ "file-type": "^21.0.0",
41
42
  "fs-extra": "^11.3.1",
42
43
  "glob": "^11.0.3",
43
44
  "gpt-tokenizer": "^3.2.0",
@@ -54,7 +55,6 @@
54
55
  "remark-gfm": "^4.0.1",
55
56
  "remark-lint": "^10.0.1",
56
57
  "remark-parse": "^11.0.0",
57
- "slugify": "^1.6.6",
58
58
  "terminal-link": "^4.0.0",
59
59
  "ufo": "^1.6.1",
60
60
  "unified": "^11.0.5",
@@ -1,11 +1,16 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { access, readFile, stat } from "node:fs/promises";
3
- import path from "node:path";
4
+ import path, { join } from "node:path";
4
5
  import { glob } from "glob";
6
+ import fs from "fs-extra";
5
7
  import { isBinaryFile } from "isbinaryfile";
6
8
  import { encode } from "gpt-tokenizer";
9
+ import { fileTypeFromBuffer } from "file-type";
10
+ import { gunzipSync } from "node:zlib";
7
11
  import { isGlobPattern } from "./utils.mjs";
8
12
  import { INTELLIGENT_SUGGESTION_TOKEN_THRESHOLD } from "./constants/index.mjs";
13
+ import { uploadFiles } from "./upload-files.mjs";
9
14
 
10
15
  /**
11
16
  * Check if a directory is inside a git repository using git command
@@ -644,3 +649,182 @@ export function getMediaDescriptionCachePath() {
644
649
  const cwd = process.cwd();
645
650
  return path.join(cwd, ".aigne", "doc-smith", "media-description.yaml");
646
651
  }
652
+
653
+ /**
654
+ * Detect file type from buffer with comprehensive fallback strategy
655
+ * @param {Buffer} buffer - File buffer
656
+ * @param {string} contentType - HTTP Content-Type header
657
+ * @param {string} url - Original URL (for fallback)
658
+ * @returns {Promise<{ext: string, mime: string}>} File extension and MIME type
659
+ */
660
+ export async function detectFileType(buffer, contentType, url = "") {
661
+ // 1. Try file-type for binary images (PNG, JPG, WebP, GIF, etc.)
662
+ try {
663
+ const fileType = await fileTypeFromBuffer(buffer);
664
+ if (fileType) {
665
+ return {
666
+ ext: fileType.ext,
667
+ mime: fileType.mime,
668
+ };
669
+ }
670
+ } catch (error) {
671
+ console.debug("file-type detection failed:", error.message);
672
+ }
673
+
674
+ // 2. Check for SVG/SVGZ
675
+ const svgResult = await detectSvgType(buffer, contentType);
676
+ if (svgResult) {
677
+ return svgResult;
678
+ }
679
+
680
+ // 3. Fallback to Content-Type
681
+ if (contentType) {
682
+ const ext = getExtnameFromContentType(contentType);
683
+ if (ext) {
684
+ return {
685
+ ext,
686
+ mime: contentType.split(";")[0].trim(),
687
+ };
688
+ }
689
+ }
690
+
691
+ // 4. Fallback to URL extension
692
+ if (url) {
693
+ const urlExt = path.extname(url).toLowerCase();
694
+ if (urlExt) {
695
+ return {
696
+ ext: urlExt.slice(1), // Remove leading dot
697
+ mime: getMimeType(url),
698
+ };
699
+ }
700
+ }
701
+
702
+ // 5. Default fallback
703
+ return {
704
+ ext: "bin",
705
+ mime: "application/octet-stream",
706
+ };
707
+ }
708
+
709
+ /**
710
+ * Detect SVG/SVGZ file type
711
+ * @param {Buffer} buffer - File buffer
712
+ * @param {string} contentType - HTTP Content-Type header
713
+ * @returns {Promise<{ext: string, mime: string} | null>} SVG info or null
714
+ */
715
+ async function detectSvgType(buffer, contentType) {
716
+ // Check Content-Type first
717
+ if (contentType?.includes("image/svg+xml")) {
718
+ return {
719
+ ext: "svg",
720
+ mime: "image/svg+xml",
721
+ };
722
+ }
723
+
724
+ try {
725
+ let text = "";
726
+
727
+ // Check if it's gzipped (SVGZ)
728
+ if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) {
729
+ try {
730
+ const decompressed = gunzipSync(buffer);
731
+ text = decompressed.toString("utf8");
732
+ if (isSvgContent(text)) {
733
+ return {
734
+ ext: "svgz",
735
+ mime: "image/svg+xml",
736
+ };
737
+ }
738
+ } catch {
739
+ // Not gzipped, continue with regular text detection
740
+ }
741
+ }
742
+
743
+ // Try as regular text
744
+ if (!text) {
745
+ text = buffer.toString("utf8");
746
+ }
747
+
748
+ if (isSvgContent(text)) {
749
+ return {
750
+ ext: "svg",
751
+ mime: "image/svg+xml",
752
+ };
753
+ }
754
+ } catch (error) {
755
+ console.debug("SVG detection failed:", error.message);
756
+ }
757
+
758
+ return null;
759
+ }
760
+
761
+ /**
762
+ * Check if text content is SVG
763
+ * @param {string} text - Text content
764
+ * @returns {boolean} True if SVG
765
+ */
766
+ function isSvgContent(text) {
767
+ if (!text || typeof text !== "string") return false;
768
+
769
+ // Remove BOM and trim
770
+ const cleanText = text.replace(/^\uFEFF/, "").trim();
771
+
772
+ // Check for SVG root element
773
+ return /^<\?xml\s+[^>]*>\s*<svg/i.test(cleanText) || /^<svg/i.test(cleanText);
774
+ }
775
+
776
+ /**
777
+ * Download and upload a remote image URL
778
+ * @param {string} imageUrl - The remote image URL
779
+ * @param {string} docsDir - Directory to save temporary files
780
+ * @param {string} appUrl - Application URL for upload
781
+ * @param {string} accessToken - Access token for upload
782
+ * @returns {Promise<{url: string, downloadFinalPath: string | null}>} The uploaded image URL and final path if failed
783
+ */
784
+ export async function downloadAndUploadImage(imageUrl, docsDir, appUrl, accessToken) {
785
+ let downloadFinalPath = null;
786
+
787
+ try {
788
+ // 1. Download with timeout control
789
+ const controller = new AbortController();
790
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
791
+
792
+ const response = await fetch(imageUrl, {
793
+ signal: controller.signal,
794
+ });
795
+ clearTimeout(timeoutId);
796
+
797
+ if (!response.ok) {
798
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
799
+ }
800
+
801
+ // 2. Convert to Buffer for file type detection
802
+ const blob = await response.blob();
803
+ const arrayBuffer = await blob.arrayBuffer();
804
+ const buffer = Buffer.from(arrayBuffer);
805
+
806
+ // 3. Detect file type with comprehensive fallback
807
+ const contentType = response.headers.get("content-type");
808
+ const { ext } = await detectFileType(buffer, contentType, imageUrl);
809
+
810
+ // 4. Generate random filename and save file
811
+ const randomId = randomBytes(16).toString("hex");
812
+ const tempFilePath = join(docsDir, `temp-logo-${randomId}`);
813
+ downloadFinalPath = ext ? `${tempFilePath}.${ext}` : tempFilePath;
814
+ fs.writeFileSync(downloadFinalPath, buffer);
815
+
816
+ // 5. Upload and get URL
817
+ const { results: uploadResults } = await uploadFiles({
818
+ appUrl,
819
+ filePaths: [downloadFinalPath],
820
+ accessToken,
821
+ concurrency: 1,
822
+ });
823
+
824
+ // 6. Return uploaded URL
825
+ return { url: uploadResults?.[0]?.url || imageUrl, downloadFinalPath };
826
+ } catch (error) {
827
+ console.warn(`Failed to download and upload image from ${imageUrl}: ${error.message}`);
828
+ return { url: imageUrl, downloadFinalPath: null };
829
+ }
830
+ }