Skip to content

Commit d9e8601

Browse files
committed
feat(knowledge): 支持上传目录路径并递归扫描文件
- 支持上传参数中传入目录路径,递归扫描子目录下文件 - 自动忽略 node_modules、.git 等常见工具目录 - 不支持的文件格式不会报错,跳过并列表提示 - 上传时校验扩展名和大小限制,支持批量文件上传 - 输出中增加跳过的文件列表,verbose 模式下显示详细文件名 - 测试覆盖目录上传、文件跳过和空目录等场景 - 更新相关文档,说明新支持的目录上传功能及注意事项
1 parent e3bb5a7 commit d9e8601

6 files changed

Lines changed: 423 additions & 152 deletions

File tree

packages/commands/src/commands/knowledge/doc-upload.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import {
2525
pollImportJob,
2626
withPartialSuccessHint,
2727
} from "./shared.ts";
28-
import { checkUploadFile } from "./upload-support.ts";
28+
import { checkUploadFile, expandUploadPaths } from "./upload-support.ts";
2929

3030
const DOC_UPLOAD_FLAGS = {
3131
file: {
3232
type: "array",
3333
valueHint: "<path>",
34-
description: "Local file path (repeatable). Extension and size validated before upload",
34+
description:
35+
"Local file or directory path (repeatable). Directories are scanned recursively; unsupported formats are skipped",
3536
required: true,
3637
},
3738
indexId: {
@@ -67,18 +68,22 @@ interface UploadedFile {
6768
}
6869

6970
export default defineCommand({
70-
description: "Upload local files to the data center and optionally import into a knowledge base",
71+
description:
72+
"Upload local files or directories to the data center and optionally import into a knowledge base",
7173
auth: "apiKey",
7274
usageArgs: "--file <path> [flags]",
7375
flags: DOC_UPLOAD_FLAGS,
7476
notes: [
7577
"Pipeline: apply upload lease → PUT to OSS → register file → (with --index-id) create import job.",
7678
"Without --category-id the workspace default category is resolved automatically.",
79+
"Directories are scanned recursively; node_modules, .git, and similar are skipped automatically.",
7780
"Multiple files are processed sequentially; on failure, already-registered file ids are listed in the error hint.",
7881
],
7982
exampleArgs: [
8083
"--file ./a.md --workspace-id ws-xxx",
8184
"--file ./a.md --file ./b.pdf --index-id idx-xxx --wait",
85+
"--file ./docs/ --workspace-id ws-xxx",
86+
"--file ./docs/ --dry-run --verbose",
8287
],
8388
validate(flags) {
8489
if (flags.wait && !flags.indexId) return "--wait requires --index-id";
@@ -89,9 +94,20 @@ export default defineCommand({
8994
const workspaceId = resolveWorkspaceId(ctx);
9095
const format = detectOutputFormat(settings.output);
9196

97+
// Expand directories into individual file paths; unsupported extensions are
98+
// collected into `skipped` rather than throwing (directory-scan semantics)
99+
const { files: expandedFiles, skipped } = expandUploadPaths(flags.file);
100+
if (expandedFiles.length === 0) {
101+
throw new BailianError(
102+
"No supported files found",
103+
ExitCode.USAGE,
104+
`Supported formats: .pdf .doc .docx .ppt .pptx .xls .xlsx .csv .md .txt .html .png .jpg .jpeg .bmp .gif`,
105+
);
106+
}
107+
92108
// Local pre-flight validation also runs in dry-run (rehearsal semantics: surface
93109
// file problems early); exceeding a soft limit only warns
94-
const checkedFiles = flags.file.map((filePath) => {
110+
const checkedFiles = expandedFiles.map((filePath) => {
95111
const checked = checkUploadFile(filePath);
96112
if (checked.warning) process.stderr.write(`Warning: ${checked.warning}\n`);
97113
return { filePath, sizeBytes: checked.sizeBytes };
@@ -140,7 +156,7 @@ export default defineCommand({
140156
} as unknown,
141157
});
142158
}
143-
emitResult({ steps }, format);
159+
emitResult({ steps, skipped }, format);
144160
return;
145161
}
146162

@@ -278,12 +294,25 @@ export default defineCommand({
278294
}
279295
if (ingestionId) emitBare(`job: ${ingestionId}`);
280296
if (finalStatus) emitBare(`status: ${finalStatus}`);
297+
// Summary line: always show counts; list skipped files only with --verbose
298+
const summaryParts = [`Uploaded ${uploaded.length} file${uploaded.length !== 1 ? "s" : ""}`];
299+
if (skipped.length > 0) {
300+
summaryParts.push(`skipped ${skipped.length} unsupported`);
301+
}
302+
emitBare(`\n${summaryParts.join(", ")}.`);
303+
if (settings.verbose && skipped.length > 0) {
304+
emitBare("Skipped files:");
305+
for (const skippedPath of skipped) {
306+
emitBare(` ${basename(skippedPath)}`);
307+
}
308+
}
281309
return;
282310
}
283311
// An orchestration command has no single response to pass through — emit a custom stable shape
284312
emitResult(
285313
{
286314
files: uploaded.map((item) => ({ path: item.path, fileId: item.fileId })),
315+
skipped,
287316
...(flags.indexId ? { index_id: flags.indexId } : {}),
288317
...(ingestionId ? { ingestion_id: ingestionId } : {}),
289318
...(finalStatus ? { final_status: finalStatus } : {}),

packages/commands/src/commands/knowledge/upload-support.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Local pre-flight validation for file uploads (doc upload).
22
// Default category: the lease/addFile `category` parameter accepts the literal
33
// "default" (verified against the live API), so no listCategory resolution is needed.
4-
import { readFileSync, statSync } from "node:fs";
5-
import { basename, extname } from "node:path";
4+
import { readFileSync, readdirSync, statSync } from "node:fs";
5+
import { basename, extname, join } from "node:path";
66
import { BailianError, ExitCode } from "bailian-cli-core";
77

88
const MB = 1024 * 1024;
@@ -37,6 +37,105 @@ export const UPLOAD_FORMAT_RULES: Record<string, UploadFormatRule> = {
3737
".html": { maxBytes: 10 * MB, enforce: "warn" },
3838
};
3939

40+
/** Returns true when the extension is in the upload format allowlist. */
41+
export function isSupportedExtension(filePath: string): boolean {
42+
const extension = extname(filePath).toLowerCase();
43+
return extension in UPLOAD_FORMAT_RULES;
44+
}
45+
46+
/**
47+
* Directory names skipped when expanding a directory path (recursive scan).
48+
* Covers common tooling artifacts that should never contain user documents.
49+
*/
50+
const IGNORED_DIRECTORIES = new Set([
51+
"node_modules",
52+
".git",
53+
".svn",
54+
".hg",
55+
"__pycache__",
56+
".venv",
57+
"venv",
58+
".env",
59+
".tox",
60+
"dist",
61+
"build",
62+
".cache",
63+
".next",
64+
".nuxt",
65+
]);
66+
67+
export interface ExpandResult {
68+
files: string[];
69+
skipped: string[];
70+
}
71+
72+
/**
73+
* Expand an array of paths into individual file paths.
74+
* - Regular files are included as-is.
75+
* - Directories are recursively scanned; files with unsupported extensions are
76+
* collected into `skipped` instead of throwing.
77+
* - Common tooling directories (node_modules, .git, …) are silently skipped.
78+
* - A non-existent path throws USAGE so the user gets a clear error.
79+
*/
80+
export function expandUploadPaths(paths: string[]): ExpandResult {
81+
const files: string[] = [];
82+
const skipped: string[] = [];
83+
84+
function walkDirectory(directoryPath: string): void {
85+
let entries: import("node:fs").Dirent[];
86+
try {
87+
entries = readdirSync(directoryPath, { withFileTypes: true });
88+
} catch (error) {
89+
const errno = (error as { code?: string }).code ?? "unknown";
90+
throw new BailianError(
91+
`Cannot read directory: ${directoryPath}`,
92+
ExitCode.GENERAL,
93+
`File system error (${errno}) — check the path and permissions.`,
94+
);
95+
}
96+
for (const entry of entries) {
97+
const entryFullPath = join(directoryPath, entry.name);
98+
if (entry.isDirectory()) {
99+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
100+
walkDirectory(entryFullPath);
101+
}
102+
continue;
103+
}
104+
if (entry.isFile()) {
105+
if (isSupportedExtension(entry.name)) {
106+
files.push(entryFullPath);
107+
} else {
108+
skipped.push(entryFullPath);
109+
}
110+
}
111+
// Symlinks: withFileTypes follows symlinks for isFile/isDirectory,
112+
// so they are handled by the branches above.
113+
}
114+
}
115+
116+
for (const inputPath of paths) {
117+
let pathStat: import("node:fs").Stats;
118+
try {
119+
pathStat = statSync(inputPath);
120+
} catch (error) {
121+
const errno = (error as { code?: string }).code ?? "unknown";
122+
throw new BailianError(
123+
`Cannot read path: ${inputPath}`,
124+
ExitCode.GENERAL,
125+
`File system error (${errno}) — check the path and permissions.`,
126+
);
127+
}
128+
if (pathStat.isDirectory()) {
129+
walkDirectory(inputPath);
130+
} else if (pathStat.isFile()) {
131+
files.push(inputPath);
132+
}
133+
// Other types (socket, block device, etc.) are silently ignored.
134+
}
135+
136+
return { files, skipped };
137+
}
138+
40139
/** Local pre-flight check before reading the file: extension allowlist + hard/soft size limits. File I/O failure → GENERAL + errno hint. */
41140
export function checkUploadFile(filePath: string): { sizeBytes: number; warning?: string } {
42141
const extension = extname(filePath).toLowerCase();

packages/commands/tests/e2e/knowledge/knowledge-doc-upload.e2e.test.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdtempSync, writeFileSync } from "node:fs";
1+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { describe, expect, test } from "vite-plus/test";
@@ -104,11 +104,13 @@ describe("e2e: knowledge doc upload", () => {
104104
"json",
105105
]);
106106
expect(exitCode, stderr).toBe(0);
107-
const data = parseStdoutJson<{ steps: DryRunStep[] }>(stdout);
107+
const data = parseStdoutJson<{ steps: DryRunStep[]; skipped: string[] }>(stdout);
108108
expect(data.steps).toHaveLength(3);
109109
const leaseRequest = data.steps[0]!.request as { sizeBytes?: unknown; category?: string };
110110
expect(typeof leaseRequest.sizeBytes).toBe("string"); // gotcha: sizeBytes must be a string
111111
expect(leaseRequest.category).toBe("cate_test");
112+
// Single file path → no skipped entries
113+
expect(data.skipped).toEqual([]);
112114
});
113115

114116
test("--dry-run 带 --index-id 输出 4 步且 job 请求含显式 sourceType", async () => {
@@ -129,7 +131,7 @@ describe("e2e: knowledge doc upload", () => {
129131
"json",
130132
]);
131133
expect(exitCode, stderr).toBe(0);
132-
const data = parseStdoutJson<{ steps: DryRunStep[] }>(stdout);
134+
const data = parseStdoutJson<{ steps: DryRunStep[]; skipped: string[] }>(stdout);
133135
expect(data.steps).toHaveLength(4);
134136
const jobRequest = data.steps[3]!.request as {
135137
indexId?: string;
@@ -141,6 +143,56 @@ describe("e2e: knowledge doc upload", () => {
141143
expect(jobRequest.indexId).toBe("idx_test");
142144
expect(jobRequest).not.toHaveProperty("documentIds");
143145
});
146+
147+
test("--file <dir> dry-run 展开目录且 skipped 包含不支持的文件", async () => {
148+
const dirFixture = mkdtempSync(join(tmpdir(), "doc-upload-dir-e2e-"));
149+
writeFileSync(join(dirFixture, "readme.md"), "# dir fixture\n");
150+
writeFileSync(join(dirFixture, "data.csv"), "a,b,c");
151+
writeFileSync(join(dirFixture, "config.json"), "{}"); // unsupported
152+
const subDir = join(dirFixture, "sub");
153+
mkdirSync(subDir);
154+
writeFileSync(join(subDir, "notes.txt"), "hello");
155+
156+
const { stdout, stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
157+
"knowledge",
158+
"doc",
159+
"upload",
160+
"--file",
161+
dirFixture,
162+
"--category-id",
163+
"cate_test",
164+
"--workspace-id",
165+
"ws_test",
166+
"--dry-run",
167+
"--output",
168+
"json",
169+
]);
170+
expect(exitCode, stderr).toBe(0);
171+
const data = parseStdoutJson<{ steps: DryRunStep[]; skipped: string[] }>(stdout);
172+
// 3 supported files × 3 steps each = 9 steps
173+
expect(data.steps).toHaveLength(9);
174+
// config.json is the only unsupported file
175+
expect(data.skipped).toHaveLength(1);
176+
expect(data.skipped[0]).toMatch(/config\.json$/);
177+
});
178+
179+
test("--file <dir> 仅含不支持的文件报 USAGE", async () => {
180+
const unsupportedDir = mkdtempSync(join(tmpdir(), "doc-upload-unsupported-"));
181+
writeFileSync(join(unsupportedDir, "data.json"), "{}");
182+
writeFileSync(join(unsupportedDir, "script.py"), "print(1)");
183+
184+
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_UPLOAD_ROUTES, [
185+
"knowledge",
186+
"doc",
187+
"upload",
188+
"--file",
189+
unsupportedDir,
190+
"--workspace-id",
191+
"ws_test",
192+
]);
193+
expect(exitCode).toBe(2);
194+
expect(stderr).toMatch(/No supported files found/i);
195+
});
144196
});
145197

146198
// Live write artifacts (data-center files) are cleaned up in place via the file delete command.

packages/commands/tests/knowledge/knowledge-upload-support.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { mkdtempSync, writeFileSync } from "node:fs";
1+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { describe, expect, test } from "vite-plus/test";
55
import { ExitCode } from "bailian-cli-core";
66
import {
77
checkUploadFile,
8+
expandUploadPaths,
89
UPLOAD_FORMAT_RULES,
910
} from "../../src/commands/knowledge/upload-support.ts";
1011

@@ -60,3 +61,84 @@ describe("UPLOAD_FORMAT_RULES", () => {
6061
expect(UPLOAD_FORMAT_RULES[".csv"]).toBeDefined(); // inconsistent across public docs; kept in the allowlist
6162
});
6263
});
64+
65+
describe("expandUploadPaths", () => {
66+
const expandFixtureDir = mkdtempSync(join(tmpdir(), "expand-upload-"));
67+
68+
function setupExpandFixtures(): void {
69+
// Root-level files
70+
writeFileSync(join(expandFixtureDir, "readme.md"), "# root");
71+
writeFileSync(join(expandFixtureDir, "data.csv"), "a,b,c");
72+
writeFileSync(join(expandFixtureDir, "config.json"), "{}"); // unsupported
73+
writeFileSync(join(expandFixtureDir, "script.py"), "print(1)"); // unsupported
74+
75+
// Subdirectory with files
76+
const subDir = join(expandFixtureDir, "subdir");
77+
mkdirSync(subDir);
78+
writeFileSync(join(subDir, "notes.txt"), "hello");
79+
writeFileSync(join(subDir, "archive.zip"), "PK"); // unsupported
80+
81+
// node_modules should be ignored
82+
const nodeModulesDir = join(expandFixtureDir, "node_modules");
83+
mkdirSync(nodeModulesDir);
84+
writeFileSync(join(nodeModulesDir, "index.js"), "module.exports = {}");
85+
86+
// .git should be ignored
87+
const gitDir = join(expandFixtureDir, ".git");
88+
mkdirSync(gitDir);
89+
writeFileSync(join(gitDir, "HEAD"), "ref: refs/heads/main");
90+
}
91+
92+
setupExpandFixtures();
93+
94+
test("目录递归扫描: 支持的文件收集, 不支持的跳过, 忽略目录不进入", () => {
95+
const result = expandUploadPaths([expandFixtureDir]);
96+
const fileNames = result.files.map((filePath) => filePath.split("/").pop());
97+
const skippedNames = result.skipped.map((filePath) => filePath.split("/").pop());
98+
99+
expect(fileNames).toContain("readme.md");
100+
expect(fileNames).toContain("data.csv");
101+
expect(fileNames).toContain("notes.txt");
102+
// Unsupported files in root and subdir
103+
expect(skippedNames).toContain("config.json");
104+
expect(skippedNames).toContain("script.py");
105+
expect(skippedNames).toContain("archive.zip");
106+
// node_modules and .git contents should NOT appear
107+
expect(fileNames).not.toContain("index.js");
108+
expect(skippedNames).not.toContain("index.js");
109+
expect(fileNames).not.toContain("HEAD");
110+
expect(skippedNames).not.toContain("HEAD");
111+
});
112+
113+
test("单个文件路径直接返回", () => {
114+
const filePath = join(expandFixtureDir, "readme.md");
115+
const result = expandUploadPaths([filePath]);
116+
expect(result.files).toEqual([filePath]);
117+
expect(result.skipped).toEqual([]);
118+
});
119+
120+
test("混合文件和目录路径", () => {
121+
const filePath = join(expandFixtureDir, "readme.md");
122+
const result = expandUploadPaths([filePath, expandFixtureDir]);
123+
// readme.md appears once from the direct file, once from the directory scan
124+
const mdCount = result.files.filter((path) => path.endsWith("readme.md")).length;
125+
expect(mdCount).toBe(2);
126+
});
127+
128+
test("空目录返回空数组", () => {
129+
const emptyDir = mkdtempSync(join(tmpdir(), "empty-upload-"));
130+
const result = expandUploadPaths([emptyDir]);
131+
expect(result.files).toEqual([]);
132+
expect(result.skipped).toEqual([]);
133+
});
134+
135+
test("不存在的路径抛 GENERAL 且 hint 含 errno", () => {
136+
try {
137+
expandUploadPaths([join(expandFixtureDir, "nonexistent.md")]);
138+
expect.unreachable("should throw");
139+
} catch (error) {
140+
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.GENERAL);
141+
expect((error as { hint?: string }).hint).toMatch(/ENOENT/);
142+
}
143+
});
144+
});

0 commit comments

Comments
 (0)