@spaceflow/core 0.1.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/README.md +105 -0
  3. package/nest-cli.json +10 -0
  4. package/package.json +128 -0
  5. package/rspack.config.mjs +62 -0
  6. package/src/__mocks__/@opencode-ai/sdk.js +9 -0
  7. package/src/__mocks__/c12.ts +3 -0
  8. package/src/app.module.ts +18 -0
  9. package/src/config/ci.config.ts +29 -0
  10. package/src/config/config-loader.ts +101 -0
  11. package/src/config/config-reader.module.ts +16 -0
  12. package/src/config/config-reader.service.ts +133 -0
  13. package/src/config/feishu.config.ts +35 -0
  14. package/src/config/git-provider.config.ts +29 -0
  15. package/src/config/index.ts +29 -0
  16. package/src/config/llm.config.ts +110 -0
  17. package/src/config/schema-generator.service.ts +129 -0
  18. package/src/config/spaceflow.config.ts +292 -0
  19. package/src/config/storage.config.ts +33 -0
  20. package/src/extension-system/extension.interface.ts +221 -0
  21. package/src/extension-system/index.ts +1 -0
  22. package/src/index.ts +80 -0
  23. package/src/locales/en/translation.json +11 -0
  24. package/src/locales/zh-cn/translation.json +11 -0
  25. package/src/shared/claude-setup/claude-setup.module.ts +8 -0
  26. package/src/shared/claude-setup/claude-setup.service.ts +131 -0
  27. package/src/shared/claude-setup/index.ts +2 -0
  28. package/src/shared/editor-config/index.ts +23 -0
  29. package/src/shared/feishu-sdk/feishu-sdk.module.ts +77 -0
  30. package/src/shared/feishu-sdk/feishu-sdk.service.ts +130 -0
  31. package/src/shared/feishu-sdk/fieshu-card.service.ts +139 -0
  32. package/src/shared/feishu-sdk/index.ts +4 -0
  33. package/src/shared/feishu-sdk/types/card-action.ts +132 -0
  34. package/src/shared/feishu-sdk/types/card.ts +64 -0
  35. package/src/shared/feishu-sdk/types/common.ts +22 -0
  36. package/src/shared/feishu-sdk/types/index.ts +46 -0
  37. package/src/shared/feishu-sdk/types/message.ts +35 -0
  38. package/src/shared/feishu-sdk/types/module.ts +21 -0
  39. package/src/shared/feishu-sdk/types/user.ts +77 -0
  40. package/src/shared/git-provider/adapters/gitea.adapter.spec.ts +473 -0
  41. package/src/shared/git-provider/adapters/gitea.adapter.ts +499 -0
  42. package/src/shared/git-provider/adapters/github.adapter.spec.ts +341 -0
  43. package/src/shared/git-provider/adapters/github.adapter.ts +830 -0
  44. package/src/shared/git-provider/adapters/gitlab.adapter.ts +839 -0
  45. package/src/shared/git-provider/adapters/index.ts +3 -0
  46. package/src/shared/git-provider/detect-provider.spec.ts +195 -0
  47. package/src/shared/git-provider/detect-provider.ts +112 -0
  48. package/src/shared/git-provider/git-provider.interface.ts +188 -0
  49. package/src/shared/git-provider/git-provider.module.ts +73 -0
  50. package/src/shared/git-provider/git-provider.service.spec.ts +282 -0
  51. package/src/shared/git-provider/git-provider.service.ts +309 -0
  52. package/src/shared/git-provider/index.ts +7 -0
  53. package/src/shared/git-provider/parse-repo-url.spec.ts +221 -0
  54. package/src/shared/git-provider/parse-repo-url.ts +155 -0
  55. package/src/shared/git-provider/types.ts +434 -0
  56. package/src/shared/git-sdk/git-sdk-diff.utils.spec.ts +344 -0
  57. package/src/shared/git-sdk/git-sdk-diff.utils.ts +151 -0
  58. package/src/shared/git-sdk/git-sdk.module.ts +8 -0
  59. package/src/shared/git-sdk/git-sdk.service.ts +235 -0
  60. package/src/shared/git-sdk/git-sdk.types.ts +25 -0
  61. package/src/shared/git-sdk/index.ts +4 -0
  62. package/src/shared/i18n/i18n.spec.ts +96 -0
  63. package/src/shared/i18n/i18n.ts +86 -0
  64. package/src/shared/i18n/index.ts +1 -0
  65. package/src/shared/i18n/locale-detect.ts +134 -0
  66. package/src/shared/llm-jsonput/index.ts +94 -0
  67. package/src/shared/llm-jsonput/types.ts +17 -0
  68. package/src/shared/llm-proxy/adapters/claude-code.adapter.spec.ts +131 -0
  69. package/src/shared/llm-proxy/adapters/claude-code.adapter.ts +208 -0
  70. package/src/shared/llm-proxy/adapters/index.ts +4 -0
  71. package/src/shared/llm-proxy/adapters/llm-adapter.interface.ts +23 -0
  72. package/src/shared/llm-proxy/adapters/open-code.adapter.ts +342 -0
  73. package/src/shared/llm-proxy/adapters/openai.adapter.spec.ts +215 -0
  74. package/src/shared/llm-proxy/adapters/openai.adapter.ts +153 -0
  75. package/src/shared/llm-proxy/index.ts +6 -0
  76. package/src/shared/llm-proxy/interfaces/config.interface.ts +32 -0
  77. package/src/shared/llm-proxy/interfaces/index.ts +4 -0
  78. package/src/shared/llm-proxy/interfaces/message.interface.ts +48 -0
  79. package/src/shared/llm-proxy/interfaces/session.interface.ts +28 -0
  80. package/src/shared/llm-proxy/llm-proxy.module.ts +140 -0
  81. package/src/shared/llm-proxy/llm-proxy.service.spec.ts +303 -0
  82. package/src/shared/llm-proxy/llm-proxy.service.ts +132 -0
  83. package/src/shared/llm-proxy/llm-session.spec.ts +111 -0
  84. package/src/shared/llm-proxy/llm-session.ts +109 -0
  85. package/src/shared/llm-proxy/stream-logger.ts +97 -0
  86. package/src/shared/logger/index.ts +11 -0
  87. package/src/shared/logger/logger.interface.ts +93 -0
  88. package/src/shared/logger/logger.spec.ts +178 -0
  89. package/src/shared/logger/logger.ts +175 -0
  90. package/src/shared/logger/renderers/plain.renderer.ts +116 -0
  91. package/src/shared/logger/renderers/tui.renderer.ts +162 -0
  92. package/src/shared/mcp/index.ts +332 -0
  93. package/src/shared/output/index.ts +2 -0
  94. package/src/shared/output/output.module.ts +9 -0
  95. package/src/shared/output/output.service.ts +97 -0
  96. package/src/shared/package-manager/index.ts +115 -0
  97. package/src/shared/parallel/index.ts +1 -0
  98. package/src/shared/parallel/parallel-executor.ts +169 -0
  99. package/src/shared/rspack-config/index.ts +1 -0
  100. package/src/shared/rspack-config/rspack-config.ts +157 -0
  101. package/src/shared/source-utils/index.ts +130 -0
  102. package/src/shared/spaceflow-dir/index.ts +158 -0
  103. package/src/shared/storage/adapters/file.adapter.ts +113 -0
  104. package/src/shared/storage/adapters/index.ts +3 -0
  105. package/src/shared/storage/adapters/memory.adapter.ts +50 -0
  106. package/src/shared/storage/adapters/storage-adapter.interface.ts +48 -0
  107. package/src/shared/storage/index.ts +4 -0
  108. package/src/shared/storage/storage.module.ts +150 -0
  109. package/src/shared/storage/storage.service.ts +293 -0
  110. package/src/shared/storage/types.ts +51 -0
  111. package/src/shared/verbose/index.ts +73 -0
  112. package/test/app.e2e-spec.ts +22 -0
  113. package/tsconfig.build.json +4 -0
  114. package/tsconfig.json +25 -0
  115. package/tsconfig.skill.json +18 -0
  116. package/vitest.config.ts +58 -0
@@ -0,0 +1,235 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import { spawn, execSync } from "child_process";
3
+ import type { GitCommit, GitChangedFile, GitDiffFile, GitRunOptions } from "./git-sdk.types";
4
+ import { mapGitStatus, parseDiffText } from "./git-sdk-diff.utils";
5
+
6
+ @Injectable()
7
+ export class GitSdkService {
8
+ protected readonly defaultOptions: GitRunOptions = {
9
+ cwd: process.cwd(),
10
+ maxBuffer: 10 * 1024 * 1024, // 10MB
11
+ };
12
+
13
+ runCommand(args: string[], options?: GitRunOptions): Promise<string> {
14
+ const opts = { ...this.defaultOptions, ...options };
15
+
16
+ return new Promise((resolve, reject) => {
17
+ const child = spawn("git", args, {
18
+ cwd: opts.cwd,
19
+ env: process.env,
20
+ stdio: ["pipe", "pipe", "pipe"],
21
+ });
22
+
23
+ let stdout = "";
24
+ let stderr = "";
25
+
26
+ child.stdout.on("data", (data) => {
27
+ stdout += data.toString();
28
+ });
29
+
30
+ child.stderr.on("data", (data) => {
31
+ stderr += data.toString();
32
+ });
33
+
34
+ child.on("close", (code) => {
35
+ if (code === 0) {
36
+ resolve(stdout);
37
+ } else {
38
+ reject(new Error(`Git 命令失败 (${code}): ${stderr}`));
39
+ }
40
+ });
41
+
42
+ child.on("error", (err) => {
43
+ reject(err);
44
+ });
45
+ });
46
+ }
47
+
48
+ runCommandSync(args: string[], options?: GitRunOptions): string {
49
+ const opts = { ...this.defaultOptions, ...options };
50
+ return execSync(`git ${args.join(" ")}`, {
51
+ cwd: opts.cwd,
52
+ encoding: "utf-8",
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ maxBuffer: opts.maxBuffer,
55
+ });
56
+ }
57
+
58
+ getRemoteUrl(options?: GitRunOptions): string | null {
59
+ try {
60
+ return this.runCommandSync(["remote", "get-url", "origin"], options).trim();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ getCurrentBranch(options?: GitRunOptions): string | null {
67
+ try {
68
+ return this.runCommandSync(["rev-parse", "--abbrev-ref", "HEAD"], options).trim();
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ getDefaultBranch(options?: GitRunOptions): string {
75
+ try {
76
+ const result = this.runCommandSync(
77
+ ["symbolic-ref", "refs/remotes/origin/HEAD"],
78
+ options,
79
+ ).trim();
80
+ return result.replace("refs/remotes/origin/", "");
81
+ } catch {
82
+ // 回退到常见默认分支
83
+ for (const branch of ["main", "master"]) {
84
+ try {
85
+ this.runCommandSync(["rev-parse", "--verify", `origin/${branch}`], options);
86
+ return branch;
87
+ } catch {
88
+ continue;
89
+ }
90
+ }
91
+ return "main";
92
+ }
93
+ }
94
+
95
+ parseRepositoryFromRemoteUrl(remoteUrl: string): { owner: string; repo: string } | null {
96
+ const match = remoteUrl.match(/[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
97
+ if (match) {
98
+ return { owner: match[1], repo: match[2] };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ async getChangedFilesBetweenRefs(
104
+ baseRef: string,
105
+ headRef: string,
106
+ options?: GitRunOptions,
107
+ ): Promise<GitChangedFile[]> {
108
+ const resolvedBase = await this.resolveRef(baseRef, options);
109
+ const resolvedHead = await this.resolveRef(headRef, options);
110
+ const result = await this.runCommand(
111
+ ["diff", "--name-status", `${resolvedBase}..${resolvedHead}`],
112
+ options,
113
+ );
114
+
115
+ const files: GitChangedFile[] = [];
116
+ const lines = result.trim().split("\n").filter(Boolean);
117
+
118
+ for (const line of lines) {
119
+ const [status, filename] = line.split("\t");
120
+ files.push({
121
+ filename,
122
+ status: mapGitStatus(status),
123
+ });
124
+ }
125
+
126
+ return files;
127
+ }
128
+
129
+ /**
130
+ * 获取两个 ref 之间的 diff(包含 patch 信息)
131
+ * @param baseRef 基准 ref
132
+ * @param headRef 目标 ref
133
+ * @param options 运行选项
134
+ * @returns 包含 filename 和 patch 的文件列表
135
+ */
136
+ async getDiffBetweenRefs(
137
+ baseRef: string,
138
+ headRef: string,
139
+ options?: GitRunOptions,
140
+ ): Promise<GitDiffFile[]> {
141
+ const resolvedBase = await this.resolveRef(baseRef, options);
142
+ const resolvedHead = await this.resolveRef(headRef, options);
143
+ const result = await this.runCommand(["diff", `${resolvedBase}..${resolvedHead}`], options);
144
+
145
+ return parseDiffText(result);
146
+ }
147
+
148
+ async getCommitsBetweenRefs(
149
+ baseRef: string,
150
+ headRef: string,
151
+ options?: GitRunOptions,
152
+ ): Promise<GitCommit[]> {
153
+ const resolvedBase = await this.resolveRef(baseRef, options);
154
+ const resolvedHead = await this.resolveRef(headRef, options);
155
+ const result = await this.runCommand(
156
+ ["log", "--format=%H|%s|%an|%ae|%aI", `${resolvedBase}..${resolvedHead}`],
157
+ options,
158
+ );
159
+
160
+ const commits: GitCommit[] = [];
161
+ const lines = result.trim().split("\n").filter(Boolean);
162
+
163
+ for (const line of lines) {
164
+ const [sha, message, authorName, authorEmail, date] = line.split("|");
165
+ commits.push({
166
+ sha,
167
+ message,
168
+ author: {
169
+ name: authorName,
170
+ email: authorEmail,
171
+ date,
172
+ },
173
+ });
174
+ }
175
+
176
+ return commits;
177
+ }
178
+
179
+ async getFilesForCommit(sha: string, options?: GitRunOptions): Promise<string[]> {
180
+ const result = await this.runCommand(["show", "--name-only", "--format=", sha], options);
181
+ return result.trim().split("\n").filter(Boolean);
182
+ }
183
+
184
+ async getFileContent(ref: string, filename: string, options?: GitRunOptions): Promise<string> {
185
+ return this.runCommand(["show", `${ref}:${filename}`], options);
186
+ }
187
+
188
+ getCommitDiff(sha: string, options?: GitRunOptions): GitDiffFile[] {
189
+ try {
190
+ const output = this.runCommandSync(["show", "--format=", "--patch", sha], options);
191
+ return parseDiffText(output);
192
+ } catch (error) {
193
+ console.warn(`⚠️ git show 失败: ${error instanceof Error ? error.message : String(error)}`);
194
+ return [];
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 解析 ref,支持本地分支、远程分支、commit SHA
200
+ * 优先级:commit SHA > 本地分支 > origin/分支 > fetch后重试 > 原始值
201
+ */
202
+ async resolveRef(ref: string, options?: GitRunOptions): Promise<string> {
203
+ if (!ref) {
204
+ throw new Error(`resolveRef: ref 参数不能为空。调用栈: ${new Error().stack}`);
205
+ }
206
+ if (/^[0-9a-f]{7,40}$/i.test(ref)) {
207
+ return ref;
208
+ }
209
+ if (ref.startsWith("origin/")) {
210
+ return ref;
211
+ }
212
+ try {
213
+ await this.runCommand(["rev-parse", "--verify", ref], options);
214
+ return ref;
215
+ } catch {
216
+ // 本地分支不存在
217
+ }
218
+ try {
219
+ await this.runCommand(["rev-parse", "--verify", `origin/${ref}`], options);
220
+ return `origin/${ref}`;
221
+ } catch {
222
+ // origin/分支也不存在
223
+ }
224
+ try {
225
+ await this.runCommand(
226
+ ["fetch", "origin", `${ref}:refs/remotes/origin/${ref}`, "--depth=1"],
227
+ options,
228
+ );
229
+ return `origin/${ref}`;
230
+ } catch {
231
+ // fetch 失败
232
+ }
233
+ return ref;
234
+ }
235
+ }
@@ -0,0 +1,25 @@
1
+ export interface GitCommit {
2
+ sha: string;
3
+ message: string;
4
+ author?: {
5
+ name?: string;
6
+ email?: string;
7
+ date?: string;
8
+ };
9
+ }
10
+
11
+ export interface GitChangedFile {
12
+ filename: string;
13
+ status: string;
14
+ patch?: string;
15
+ }
16
+
17
+ export interface GitDiffFile {
18
+ filename: string;
19
+ patch: string;
20
+ }
21
+
22
+ export interface GitRunOptions {
23
+ cwd?: string;
24
+ maxBuffer?: number;
25
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./git-sdk.types";
2
+ export * from "./git-sdk-diff.utils";
3
+ export { GitSdkService } from "./git-sdk.service";
4
+ export { GitSdkModule } from "./git-sdk.module";
@@ -0,0 +1,96 @@
1
+ import { initI18n, t, addLocaleResources, resetI18n } from "./i18n";
2
+ import zhCN from "../../locales/zh-cn/translation.json";
3
+ import en from "../../locales/en/translation.json";
4
+
5
+ beforeEach(() => {
6
+ resetI18n();
7
+ });
8
+
9
+ describe("initI18n", () => {
10
+ it("默认初始化为 zh-CN", () => {
11
+ initI18n("zh-CN");
12
+ expect(t("common.options.dryRun")).toBe(zhCN["common.options.dryRun"]);
13
+ });
14
+
15
+ it("可以指定英文", () => {
16
+ initI18n("en");
17
+ expect(t("common.options.dryRun")).toBe(en["common.options.dryRun"]);
18
+ });
19
+ });
20
+
21
+ describe("t", () => {
22
+ it("返回中文公共翻译", () => {
23
+ initI18n("zh-CN");
24
+ expect(t("common.executionFailed", { error: "test" })).toBe("执行失败: test");
25
+ });
26
+
27
+ it("返回英文公共翻译", () => {
28
+ initI18n("en");
29
+ expect(t("common.executionFailed", { error: "test" })).toBe("Execution failed: test");
30
+ });
31
+
32
+ it("key 不存在时返回 key 本身", () => {
33
+ initI18n("zh-CN");
34
+ expect(t("nonexistent.key")).toBe("nonexistent.key");
35
+ });
36
+
37
+ it("未初始化时自动初始化", () => {
38
+ const result = t("common.options.dryRun");
39
+ expect(typeof result).toBe("string");
40
+ expect(result.length).toBeGreaterThan(0);
41
+ });
42
+ });
43
+
44
+ describe("addLocaleResources", () => {
45
+ it("注册 Extension 命名空间并可通过 t 访问(中文)", () => {
46
+ initI18n("zh-CN");
47
+ addLocaleResources("build", {
48
+ "zh-CN": { description: "构建插件", buildFailed: "构建失败: {{error}}" },
49
+ en: { description: "Build plugins", buildFailed: "Build failed: {{error}}" },
50
+ });
51
+ expect(t("build:description")).toBe("构建插件");
52
+ expect(t("build:buildFailed", { error: "test" })).toBe("构建失败: test");
53
+ });
54
+
55
+ it("注册 Extension 命名空间并可通过 t 访问(英文)", () => {
56
+ initI18n("en");
57
+ addLocaleResources("build", {
58
+ "zh-CN": { description: "构建插件" },
59
+ en: { description: "Build plugins" },
60
+ });
61
+ expect(t("build:description")).toBe("Build plugins");
62
+ });
63
+
64
+ it("多个命名空间互不干扰", () => {
65
+ initI18n("zh-CN");
66
+ addLocaleResources("ext-a", {
67
+ "zh-CN": { name: "扩展A" },
68
+ en: { name: "Extension A" },
69
+ });
70
+ addLocaleResources("ext-b", {
71
+ "zh-CN": { name: "扩展B" },
72
+ en: { name: "Extension B" },
73
+ });
74
+ expect(t("ext-a:name")).toBe("扩展A");
75
+ expect(t("ext-b:name")).toBe("扩展B");
76
+ });
77
+ });
78
+
79
+ describe("语言包完整性", () => {
80
+ it("zh-cn 和 en 的 key 数量一致", () => {
81
+ const zhKeys = Object.keys(zhCN).sort();
82
+ const enKeys = Object.keys(en).sort();
83
+ expect(zhKeys).toEqual(enKeys);
84
+ });
85
+
86
+ it("所有 key 的值都不为空", () => {
87
+ for (const [_key, value] of Object.entries(zhCN)) {
88
+ expect(value).toBeTruthy();
89
+ expect(typeof value).toBe("string");
90
+ }
91
+ for (const [_key, value] of Object.entries(en)) {
92
+ expect(value).toBeTruthy();
93
+ expect(typeof value).toBe("string");
94
+ }
95
+ });
96
+ });
@@ -0,0 +1,86 @@
1
+ import * as i18nextModule from "i18next";
2
+ import type { TOptions, i18n } from "i18next";
3
+
4
+ // 兼容 CJS/ESM 混合环境
5
+ const i18next: i18n =
6
+ (i18nextModule as unknown as { default: i18n }).default || (i18nextModule as unknown as i18n);
7
+ import { detectLocale } from "./locale-detect";
8
+ import zhCN from "../../locales/zh-cn/translation.json";
9
+ import en from "../../locales/en/translation.json";
10
+
11
+ /** 默认命名空间 */
12
+ const DEFAULT_NS = "translation";
13
+
14
+ /** 是否已初始化 */
15
+ let initialized = false;
16
+
17
+ /**
18
+ * 初始化 i18n
19
+ * 当提供 resources 且无后端加载器时,i18next.init() 同步完成
20
+ * @param lang 指定语言,不传则自动检测
21
+ */
22
+ export function initI18n(lang?: string): void {
23
+ if (initialized) return;
24
+ const lng = lang || detectLocale();
25
+ // i18next v25+ 移除了 initSync,但提供内联 resources 时 init() 同步完成
26
+ void i18next.init({
27
+ lng,
28
+ fallbackLng: "zh-CN",
29
+ defaultNS: DEFAULT_NS,
30
+ ns: [DEFAULT_NS],
31
+ resources: {
32
+ "zh-CN": { [DEFAULT_NS]: zhCN },
33
+ en: { [DEFAULT_NS]: en },
34
+ },
35
+ interpolation: {
36
+ escapeValue: false,
37
+ },
38
+ returnNull: false,
39
+ returnEmptyString: false,
40
+ // i18next v25.8+ 会在 init 时输出 locize.com 推广日志
41
+ showSupportNotice: false,
42
+ });
43
+ initialized = true;
44
+ }
45
+
46
+ /**
47
+ * 重置 i18n 状态(仅用于测试)
48
+ */
49
+ export function resetI18n(): void {
50
+ initialized = false;
51
+ }
52
+
53
+ /**
54
+ * 翻译函数
55
+ * 装饰器和运行时均可使用
56
+ * @param key 翻译 key
57
+ * @param options 插值参数
58
+ */
59
+ export function t(key: string, options?: TOptions): string {
60
+ if (!initialized) {
61
+ initI18n();
62
+ }
63
+ return i18next.t(key, options) as string;
64
+ }
65
+
66
+ /**
67
+ * 为外部 Extension 注册语言资源
68
+ * @param ns 命名空间(通常为 Extension name)
69
+ * @param resources 语言资源,key 为语言代码,值为翻译对象
70
+ */
71
+ export function addLocaleResources(
72
+ ns: string,
73
+ resources: Record<string, Record<string, unknown>>,
74
+ ): void {
75
+ if (!initialized) {
76
+ initI18n();
77
+ }
78
+ for (const [lng, translations] of Object.entries(resources)) {
79
+ i18next.addResourceBundle(lng, ns, translations, true, true);
80
+ }
81
+ if (!i18next.options.ns) {
82
+ i18next.options.ns = [DEFAULT_NS, ns];
83
+ } else if (Array.isArray(i18next.options.ns) && !i18next.options.ns.includes(ns)) {
84
+ i18next.options.ns.push(ns);
85
+ }
86
+ }
@@ -0,0 +1 @@
1
+ export { initI18n, t, addLocaleResources } from "./i18n";
@@ -0,0 +1,134 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { execSync } from "child_process";
3
+ import { join } from "path";
4
+ import { homedir, platform } from "os";
5
+
6
+ /** 默认语言 */
7
+ const DEFAULT_LOCALE = "zh-CN";
8
+
9
+ /** 配置文件名 */
10
+ const CONFIG_FILE_NAME = "spaceflow.json";
11
+
12
+ /** .spaceflow 目录名 */
13
+ const SPACEFLOW_DIR = ".spaceflow";
14
+
15
+ /**
16
+ * 标准化 locale 字符串为 BCP 47 格式
17
+ * @example "zh_CN" → "zh-CN", "zh-Hans" → "zh-CN", "en_US.UTF-8" → "en-US"
18
+ */
19
+ function normalizeLocale(raw: string): string | undefined {
20
+ const cleaned = raw.replace(/\..*$/, "").trim();
21
+ // 处理 Apple 格式:zh-Hans → zh-CN, zh-Hant → zh-TW
22
+ if (/^zh[-_]?Hans/i.test(cleaned)) return "zh-CN";
23
+ if (/^zh[-_]?Hant/i.test(cleaned)) return "zh-TW";
24
+ // 处理标准格式:zh_CN / zh-CN / en_US / en-US
25
+ const match = cleaned.match(/^([a-z]{2})[-_]([A-Z]{2})/i);
26
+ if (match) return `${match[1].toLowerCase()}-${match[2].toUpperCase()}`;
27
+ // 处理纯语言代码:zh → zh-CN, en → en
28
+ const langOnly = cleaned.match(/^([a-z]{2})$/i);
29
+ if (langOnly) {
30
+ const lang = langOnly[1].toLowerCase();
31
+ return lang === "zh" ? "zh-CN" : lang;
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ /**
37
+ * 从 spaceflow.json 读取 lang 字段
38
+ * 按优先级从高到低查找:项目 > 全局
39
+ */
40
+ function readLangFromConfig(): string | undefined {
41
+ const paths = [
42
+ join(process.cwd(), SPACEFLOW_DIR, CONFIG_FILE_NAME),
43
+ join(homedir(), SPACEFLOW_DIR, CONFIG_FILE_NAME),
44
+ ];
45
+ for (const configPath of paths) {
46
+ if (!existsSync(configPath)) continue;
47
+ try {
48
+ const content = readFileSync(configPath, "utf-8");
49
+ const config = JSON.parse(content) as Record<string, unknown>;
50
+ if (typeof config.lang === "string" && config.lang.length > 0) {
51
+ return normalizeLocale(config.lang) ?? config.lang;
52
+ }
53
+ } catch {
54
+ // 忽略解析错误
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * 从系统环境变量推断 locale
62
+ * 解析 LANG 格式如 "zh_CN.UTF-8" → "zh-CN"
63
+ */
64
+ function readEnvLocale(): string | undefined {
65
+ const raw = process.env.LC_ALL || process.env.LANG;
66
+ if (!raw) return undefined;
67
+ return normalizeLocale(raw);
68
+ }
69
+
70
+ /**
71
+ * macOS:通过 defaults read 获取系统首选语言
72
+ * AppleLanguages 比 LANG 环境变量更准确地反映用户实际语言偏好
73
+ */
74
+ function readMacOSLocale(): string | undefined {
75
+ try {
76
+ const output = execSync("defaults read -g AppleLanguages", {
77
+ encoding: "utf-8",
78
+ timeout: 500,
79
+ stdio: ["pipe", "pipe", "pipe"],
80
+ });
81
+ // 输出格式: (\n "zh-Hans-CN",\n "en-CN"\n)
82
+ const match = output.match(/"([^"]+)"/);
83
+ if (!match) return undefined;
84
+ return normalizeLocale(match[1]);
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Windows:通过 PowerShell 获取系统 UI 语言
92
+ */
93
+ function readWindowsLocale(): string | undefined {
94
+ try {
95
+ const output = execSync('powershell -NoProfile -Command "(Get-Culture).Name"', {
96
+ encoding: "utf-8",
97
+ timeout: 1000,
98
+ stdio: ["pipe", "pipe", "pipe"],
99
+ });
100
+ const trimmed = output.trim();
101
+ if (!trimmed) return undefined;
102
+ return normalizeLocale(trimmed);
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * 读取操作系统级别的语言偏好
110
+ * macOS: defaults read -g AppleLanguages
111
+ * Windows: PowerShell Get-Culture
112
+ */
113
+ function readOSLocale(): string | undefined {
114
+ const os = platform();
115
+ if (os === "darwin") return readMacOSLocale();
116
+ if (os === "win32") return readWindowsLocale();
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * 检测当前语言
122
+ *
123
+ * 优先级:
124
+ * 1. 环境变量 SPACEFLOW_LANG
125
+ * 2. spaceflow.json 中的 lang 字段(项目 > 全局)
126
+ * 3. 操作系统语言偏好(macOS AppleLanguages / Windows Get-Culture)
127
+ * 4. 系统环境变量 LC_ALL / LANG
128
+ * 5. 回退到 zh-CN
129
+ */
130
+ export function detectLocale(): string {
131
+ const envLang = process.env.SPACEFLOW_LANG;
132
+ if (envLang) return normalizeLocale(envLang) ?? envLang;
133
+ return readLangFromConfig() || readOSLocale() || readEnvLocale() || DEFAULT_LOCALE;
134
+ }
@@ -0,0 +1,94 @@
1
+ import { jsonrepair } from "jsonrepair";
2
+ import type { LlmJsonPutSchema, LlmJsonSchema } from "./types";
3
+
4
+ export type { LlmJsonPutSchema, LlmJsonSchema, LlmJsonSchemaType } from "./types";
5
+
6
+ export interface ParseOptions {
7
+ disableRequestRetry?: boolean;
8
+ }
9
+
10
+ export interface LlmJsonPutOptions {
11
+ llmRequest?: (prompt: { systemPrompt: string; userPrompt: string }) => Promise<string>;
12
+ systemPrompt?: string;
13
+ }
14
+
15
+ const JSON_FORMAT_INSTRUCTION = `请严格以 JSON 格式输出结果,不要输出任何其他内容,格式如下:`;
16
+
17
+ export class LlmJsonPut<T = any> {
18
+ public readonly jsonFormatInstruction: string;
19
+ constructor(
20
+ protected schema: LlmJsonPutSchema,
21
+ protected opts?: LlmJsonPutOptions,
22
+ ) {
23
+ this.jsonFormatInstruction = this.getJsonFormatInstruction(schema);
24
+ }
25
+
26
+ isMatched(prompt: string): boolean {
27
+ return prompt.includes(JSON_FORMAT_INSTRUCTION);
28
+ }
29
+
30
+ getSchema(): Record<string, unknown> {
31
+ return this.schema as unknown as Record<string, unknown>;
32
+ }
33
+
34
+ getJsonFormatInstruction(schema: LlmJsonPutSchema): string {
35
+ const generateExample = (s: LlmJsonSchema): any => {
36
+ if (s.type === "object") {
37
+ const obj: any = {};
38
+ for (const [key, value] of Object.entries(s.properties || {})) {
39
+ obj[key] = generateExample(value);
40
+ }
41
+ return obj;
42
+ } else if (s.type === "array") {
43
+ return [generateExample(s.items!)];
44
+ }
45
+ return s.description || `<${s.type}>`;
46
+ };
47
+
48
+ const example = JSON.stringify(generateExample(schema), null, 2);
49
+
50
+ return `${JSON_FORMAT_INSTRUCTION}\n\`\`\`json\n${example}\`\`\`\n注意:只输出 JSON,不要包含 markdown 代码块或其他文字。`;
51
+ }
52
+
53
+ async parse(input: string, opts?: ParseOptions): Promise<T> {
54
+ let content = input.trim();
55
+
56
+ // 尝试移除 markdown 代码块
57
+ if (content.startsWith("```")) {
58
+ content = content.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
59
+ }
60
+
61
+ try {
62
+ try {
63
+ return JSON.parse(content);
64
+ } catch {
65
+ // 如果原生解析失败,尝试修复
66
+ const repaired = jsonrepair(content);
67
+ return JSON.parse(repaired);
68
+ }
69
+ } catch (err) {
70
+ // 如果以上都不行就都丢给大模型,然后在重新走parse,但是要记录重新执行的次数,最多5次
71
+ let retryCount = 0;
72
+ while (retryCount < 5 && !opts?.disableRequestRetry) {
73
+ const response = await this.request(input);
74
+ return response;
75
+ }
76
+ throw new Error(
77
+ `无法解析或修复 LLM 返回的 JSON: ${err instanceof Error ? err.message : String(err)}\n原始内容: ${input}`,
78
+ );
79
+ }
80
+ }
81
+
82
+ async request(userPrompt: string): Promise<T> {
83
+ if (!this.opts?.llmRequest) {
84
+ throw new Error("未配置 llmRequest 方法,无法发起请求");
85
+ }
86
+
87
+ const systemPrompt = `${this.opts.systemPrompt ? this.opts.systemPrompt + "\n" : ""}${this.jsonFormatInstruction}`;
88
+ const response = await this.opts.llmRequest({
89
+ systemPrompt,
90
+ userPrompt,
91
+ });
92
+ return this.parse(response, { disableRequestRetry: true });
93
+ }
94
+ }
@@ -0,0 +1,17 @@
1
+ export type LlmJsonSchemaType = "string" | "number" | "boolean" | "object" | "array" | "null";
2
+
3
+ export interface LlmJsonSchema {
4
+ type: LlmJsonSchemaType;
5
+ description?: string;
6
+ properties?: Record<string, LlmJsonSchema>;
7
+ items?: LlmJsonSchema;
8
+ required?: string[];
9
+ additionalProperties?: boolean;
10
+ enum?: string[];
11
+ }
12
+
13
+ export interface LlmJsonPutSchema extends LlmJsonSchema {
14
+ type: "object";
15
+ properties: Record<string, LlmJsonSchema>;
16
+ required: string[];
17
+ }