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

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.14-beta.1](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.14-beta...v0.8.14-beta.1) (2025-10-17)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * doc review and update ([#199](https://github.com/AIGNE-io/aigne-doc-smith/issues/199)) ([0061323](https://github.com/AIGNE-io/aigne-doc-smith/commit/006132345a17a7fae10a803656b731144ecc54c9))
9
+ * respect env vars for publish and improve reliability ([#204](https://github.com/AIGNE-io/aigne-doc-smith/issues/204)) ([bd14c2a](https://github.com/AIGNE-io/aigne-doc-smith/commit/bd14c2af54ebca89c8edb5861e3b1a6e3f76e92e))
10
+
11
+ ## [0.8.14-beta](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.13...v0.8.14-beta) (2025-10-16)
12
+
13
+
14
+ ### Features
15
+
16
+ * **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))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * 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))
22
+
3
23
  ## [0.8.13](https://github.com/AIGNE-io/aigne-doc-smith/compare/v0.8.13-beta...v0.8.13) (2025-10-16)
4
24
 
5
25
 
@@ -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
 
@@ -59,29 +57,27 @@ export default async function publishDocs(
59
57
 
60
58
  // ----------------- main publish process flow -----------------------------
61
59
  // Check if DOC_DISCUSS_KIT_URL is set in environment variables
62
- const envAppUrl = process.env.DOC_DISCUSS_KIT_URL;
63
- const useEnvAppUrl = !!envAppUrl;
64
-
65
- // Use environment variable if available, otherwise use the provided appUrl
66
- if (useEnvAppUrl) {
67
- appUrl = envAppUrl;
68
- }
60
+ const useEnvAppUrl = !!(process.env.DOC_DISCUSS_KIT_URL || appUrl);
69
61
 
70
62
  // Check if appUrl is default and not saved in config (only when not using env variable)
71
63
  const config = await loadConfigFromFile();
72
- const isDefaultAppUrl = appUrl === CLOUD_SERVICE_URL_PROD;
73
- const hasAppUrlInConfig = config?.appUrl;
64
+ appUrl = process.env.DOC_DISCUSS_KIT_URL || appUrl || config?.appUrl;
65
+ const hasInputAppUrl = !!appUrl;
74
66
 
67
+ let shouldSyncBranding = void 0;
75
68
  let token = "";
69
+ let client = null;
70
+ let authToken = null;
71
+ let sessionId = null;
76
72
 
77
- if (!useEnvAppUrl && isDefaultAppUrl && !hasAppUrlInConfig) {
78
- const authToken = await getOfficialAccessToken(BASE_URL, false);
73
+ if (!hasInputAppUrl) {
74
+ authToken = await getOfficialAccessToken(BASE_URL, false);
79
75
 
80
- let sessionId = "";
76
+ sessionId = "";
81
77
  let paymentLink = "";
82
78
 
83
79
  if (authToken) {
84
- const client = new BrokerClient({ baseUrl: BASE_URL, authToken });
80
+ client = new BrokerClient({ baseUrl: BASE_URL, authToken });
85
81
  const info = await client.checkCacheSession({
86
82
  needShortUrl: true,
87
83
  sessionId: config?.checkoutId,
@@ -137,29 +133,60 @@ export default async function publishDocs(
137
133
  // Ensure appUrl has protocol
138
134
  appUrl = userInput.includes("://") ? userInput : `https://${userInput}`;
139
135
  } else if (["new-instance", "new-instance-continue"].includes(choice)) {
140
- if (options?.prompts?.confirm) {
141
- shouldWithBranding = await options.prompts.confirm({
142
- message: "Would you like to update the project branding (title, description, logo)?",
143
- default: true,
144
- });
145
- }
146
- // Deploy a new Discuss Kit service
147
- let id = "";
148
- let paymentUrl = "";
136
+ // resume previous website setup
149
137
  if (choice === "new-instance-continue") {
150
- id = sessionId;
151
- paymentUrl = paymentLink;
152
- console.log(`\nResuming your previous website setup...`);
153
- } else {
154
- console.log(`\nCreating a new website for your documentation...`);
138
+ shouldSyncBranding = config?.shouldSyncBranding ?? void 0;
139
+ if (shouldSyncBranding !== void 0) {
140
+ shouldWithBranding = shouldWithBranding ?? shouldSyncBranding;
141
+ }
155
142
  }
156
- const { appUrl: homeUrl, token: ltToken } = (await deploy(id, paymentUrl)) || {};
157
143
 
158
- appUrl = homeUrl;
159
- token = ltToken;
144
+ if (options?.prompts?.confirm) {
145
+ if (shouldSyncBranding === void 0) {
146
+ shouldSyncBranding = await options.prompts.confirm({
147
+ message: "Would you like to update the project branding (title, description, logo)?",
148
+ default: true,
149
+ });
150
+ await saveValueToConfig(
151
+ "shouldSyncBranding",
152
+ shouldSyncBranding,
153
+ "Should sync branding for documentation",
154
+ );
155
+ shouldWithBranding = shouldSyncBranding;
156
+ } else {
157
+ console.log(
158
+ `Would you like to update the project branding (title, description, logo)? ${chalk.cyan(shouldSyncBranding ? "Yes" : "No")}`,
159
+ );
160
+ }
161
+ }
162
+
163
+ try {
164
+ let id = "";
165
+ if (choice === "new-instance-continue") {
166
+ id = sessionId;
167
+ console.log(`\nResuming your previous website setup...`);
168
+ } else {
169
+ console.log(`\nCreating a new website for your documentation...`);
170
+ }
171
+ const { appUrl: homeUrl, token: ltToken } = (await deploy(id, paymentLink)) || {};
172
+
173
+ appUrl = homeUrl;
174
+ token = ltToken;
175
+ } catch (error) {
176
+ const errorMsg = error?.message || "Unknown error occurred";
177
+ return { message: `${chalk.red("❌ Failed to create website:")} ${errorMsg}` };
178
+ }
160
179
  }
161
180
  }
162
181
 
182
+ if (sessionId) {
183
+ authToken = await getOfficialAccessToken(BASE_URL, false);
184
+ client = client || new BrokerClient({ baseUrl: BASE_URL, authToken });
185
+
186
+ const { vendors } = await client.getSessionDetail(sessionId, false);
187
+ token = vendors?.find((vendor) => vendor.vendorType === "launcher" && vendor.token)?.token;
188
+ }
189
+
163
190
  console.log(`\nPublishing your documentation to ${chalk.cyan(appUrl)}\n`);
164
191
 
165
192
  const accessToken = await getAccessToken(appUrl, token);
@@ -175,46 +202,22 @@ export default async function publishDocs(
175
202
  description: projectDesc || config?.projectDesc || "",
176
203
  icon: projectLogo || config?.projectLogo || "",
177
204
  };
205
+ let finalPath = null;
178
206
 
179
207
  // Handle project logo download if it's a URL
180
208
  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
- }
209
+ const { url: uploadedImageUrl, downloadFinalPath } = await downloadAndUploadImage(
210
+ projectInfo.icon,
211
+ docsDir,
212
+ appUrl,
213
+ accessToken,
214
+ );
215
+ projectInfo.icon = uploadedImageUrl;
216
+ finalPath = downloadFinalPath;
214
217
  }
215
218
 
216
219
  if (shouldWithBranding) {
217
- updateBranding({ appUrl, projectInfo, accessToken });
220
+ updateBranding({ appUrl, projectInfo, accessToken, finalPath });
218
221
  }
219
222
 
220
223
  // Construct boardMeta object
@@ -263,6 +266,7 @@ export default async function publishDocs(
263
266
  }
264
267
  message = `✅ Documentation published successfully!`;
265
268
  await saveValueToConfig("checkoutId", "", "Checkout ID for document deployment service");
269
+ await saveValueToConfig("shouldSyncBranding", "", "Should sync branding for documentation");
266
270
  } else {
267
271
  // If the error is 401 or 403, it means the access token is invalid
268
272
  if (error?.includes("401") || error?.includes("403")) {
@@ -297,7 +301,6 @@ publishDocs.input_schema = {
297
301
  appUrl: {
298
302
  type: "string",
299
303
  description: "The URL of the app.",
300
- default: CLOUD_SERVICE_URL_PROD,
301
304
  },
302
305
  boardId: {
303
306
  type: "string",
@@ -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)) {
@@ -27,6 +25,18 @@ export default async function updateBranding({ appUrl, projectInfo, accessToken
27
25
  const componentInfo = await getComponentInfoWithMountPoint(origin, DISCUSS_KIT_DID);
28
26
  const mountPoint = componentInfo.mountPoint || "/";
29
27
 
28
+ if (projectInfo.name.length > 40) {
29
+ console.warn(
30
+ `⚠️ Name is too long, it should be less than 40 characters\nWill be truncated to 40 characters`,
31
+ );
32
+ }
33
+
34
+ if (projectInfo.description.length > 160) {
35
+ console.warn(
36
+ `⚠️ Description is too long, it should be less than 160 characters\nWill be truncated to 160 characters`,
37
+ );
38
+ }
39
+
30
40
  const res = await requestWithAuthToken(
31
41
  joinURL(origin, mountPoint, "/api/branding"),
32
42
  {
@@ -35,23 +45,27 @@ export default async function updateBranding({ appUrl, projectInfo, accessToken
35
45
  "Content-Type": "application/json",
36
46
  },
37
47
  body: JSON.stringify({
38
- appName: projectInfo.name,
39
- appDescription: projectInfo.description,
48
+ appName: projectInfo.name.slice(0, 40),
49
+ appDescription: projectInfo.description.slice(0, 160),
40
50
  }),
41
51
  },
42
52
  accessToken,
43
53
  );
44
54
 
45
55
  if (res.success) {
56
+ if (!finalPath) {
57
+ console.warn("\n🔄 Skipped updating branding for missing logo file\n");
58
+ return;
59
+ }
60
+
46
61
  try {
47
- const projectLogoPath = resolve(process.cwd(), DOC_SMITH_DIR, projectInfo.icon);
48
- const projectLogoStat = await stat(projectLogoPath);
62
+ const projectLogoStat = await stat(finalPath);
49
63
 
50
64
  if (projectLogoStat.isFile()) {
51
65
  // Upload to blocklet logo endpoint
52
66
  await uploadFiles({
53
67
  appUrl: origin,
54
- filePaths: [projectLogoPath],
68
+ filePaths: [finalPath],
55
69
  accessToken,
56
70
  concurrency: 1,
57
71
  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",
3
+ "version": "0.8.14-beta.1",
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",
package/utils/deploy.mjs CHANGED
@@ -42,11 +42,6 @@ export async function deploy(id, cachedUrl) {
42
42
  sessionId,
43
43
  "Checkout ID for document deployment website",
44
44
  );
45
- await saveValueToConfig(
46
- "paymentUrl",
47
- paymentUrl,
48
- "Payment URL for document deployment website",
49
- );
50
45
 
51
46
  if (!isResuming) {
52
47
  await open(paymentUrl);
@@ -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
+ }