ideaco 1.1.5

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 (159) hide show
  1. package/.dockerignore +33 -0
  2. package/.nvmrc +1 -0
  3. package/ARCHITECTURE.md +394 -0
  4. package/Dockerfile +50 -0
  5. package/LICENSE +29 -0
  6. package/README.md +206 -0
  7. package/bin/i18n.js +46 -0
  8. package/bin/ideaco.js +494 -0
  9. package/deploy.sh +15 -0
  10. package/docker-compose.yml +30 -0
  11. package/electron/main.cjs +986 -0
  12. package/electron/preload.cjs +14 -0
  13. package/electron/web-backends.cjs +854 -0
  14. package/jsconfig.json +8 -0
  15. package/next.config.mjs +34 -0
  16. package/package.json +134 -0
  17. package/postcss.config.mjs +6 -0
  18. package/public/demo/dashboard.png +0 -0
  19. package/public/demo/employee.png +0 -0
  20. package/public/demo/messages.png +0 -0
  21. package/public/demo/office.png +0 -0
  22. package/public/demo/requirement.png +0 -0
  23. package/public/logo.jpeg +0 -0
  24. package/public/logo.png +0 -0
  25. package/scripts/prepare-electron.js +67 -0
  26. package/scripts/release.js +76 -0
  27. package/src/app/api/agents/[agentId]/chat/route.js +70 -0
  28. package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
  29. package/src/app/api/agents/[agentId]/route.js +106 -0
  30. package/src/app/api/avatar/route.js +104 -0
  31. package/src/app/api/browse-dir/route.js +44 -0
  32. package/src/app/api/chat/route.js +265 -0
  33. package/src/app/api/company/factory-reset/route.js +43 -0
  34. package/src/app/api/company/route.js +82 -0
  35. package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
  36. package/src/app/api/departments/route.js +92 -0
  37. package/src/app/api/group-chat-loop/events/route.js +70 -0
  38. package/src/app/api/group-chat-loop/route.js +94 -0
  39. package/src/app/api/mailbox/route.js +100 -0
  40. package/src/app/api/messages/route.js +14 -0
  41. package/src/app/api/providers/[id]/configure/route.js +21 -0
  42. package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
  43. package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
  44. package/src/app/api/providers/route.js +11 -0
  45. package/src/app/api/requirements/route.js +242 -0
  46. package/src/app/api/secretary/route.js +65 -0
  47. package/src/app/api/system/cli-backends/route.js +91 -0
  48. package/src/app/api/system/cron/route.js +110 -0
  49. package/src/app/api/system/knowledge/route.js +104 -0
  50. package/src/app/api/system/plugins/route.js +40 -0
  51. package/src/app/api/system/skills/route.js +46 -0
  52. package/src/app/api/system/status/route.js +46 -0
  53. package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
  54. package/src/app/api/talent-market/[profileId]/route.js +17 -0
  55. package/src/app/api/talent-market/route.js +26 -0
  56. package/src/app/api/teams/route.js +773 -0
  57. package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
  58. package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
  59. package/src/app/globals.css +130 -0
  60. package/src/app/layout.jsx +40 -0
  61. package/src/app/page.jsx +97 -0
  62. package/src/components/AgentChatModal.jsx +164 -0
  63. package/src/components/AgentDetailModal.jsx +425 -0
  64. package/src/components/AgentSpyModal.jsx +481 -0
  65. package/src/components/AvatarGrid.jsx +29 -0
  66. package/src/components/BossProfileModal.jsx +162 -0
  67. package/src/components/CachedAvatar.jsx +77 -0
  68. package/src/components/ChatPanel.jsx +219 -0
  69. package/src/components/ChatShared.jsx +255 -0
  70. package/src/components/DepartmentDetail.jsx +842 -0
  71. package/src/components/DepartmentView.jsx +367 -0
  72. package/src/components/FileReference.jsx +260 -0
  73. package/src/components/FilesView.jsx +465 -0
  74. package/src/components/GroupChatView.jsx +799 -0
  75. package/src/components/Mailbox.jsx +926 -0
  76. package/src/components/MessagesView.jsx +112 -0
  77. package/src/components/OnboardingGuide.jsx +209 -0
  78. package/src/components/OrgTree.jsx +151 -0
  79. package/src/components/Overview.jsx +391 -0
  80. package/src/components/PixelOffice.jsx +2281 -0
  81. package/src/components/ProviderGrid.jsx +551 -0
  82. package/src/components/ProvidersBoard.jsx +16 -0
  83. package/src/components/RequirementDetail.jsx +1279 -0
  84. package/src/components/RequirementsBoard.jsx +187 -0
  85. package/src/components/SecretarySettings.jsx +295 -0
  86. package/src/components/SetupWizard.jsx +388 -0
  87. package/src/components/Sidebar.jsx +169 -0
  88. package/src/components/SystemMonitor.jsx +808 -0
  89. package/src/components/TalentMarket.jsx +183 -0
  90. package/src/components/TeamDetail.jsx +697 -0
  91. package/src/core/agent/base-agent.js +104 -0
  92. package/src/core/agent/chat-store.js +602 -0
  93. package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
  94. package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
  95. package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
  96. package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
  97. package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
  98. package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
  99. package/src/core/agent/cli-agent/backends/index.js +27 -0
  100. package/src/core/agent/cli-agent/backends/registry.js +580 -0
  101. package/src/core/agent/cli-agent/index.js +154 -0
  102. package/src/core/agent/index.js +60 -0
  103. package/src/core/agent/llm-agent/client.js +320 -0
  104. package/src/core/agent/llm-agent/index.js +97 -0
  105. package/src/core/agent/message-bus.js +211 -0
  106. package/src/core/agent/session.js +608 -0
  107. package/src/core/agent/tools.js +596 -0
  108. package/src/core/agent/web-agent/backends/base-backend.js +180 -0
  109. package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
  110. package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
  111. package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
  112. package/src/core/agent/web-agent/backends/index.js +91 -0
  113. package/src/core/agent/web-agent/index.js +278 -0
  114. package/src/core/agent/web-agent/web-client.js +407 -0
  115. package/src/core/employee/base-employee.js +1088 -0
  116. package/src/core/employee/index.js +35 -0
  117. package/src/core/employee/knowledge.js +327 -0
  118. package/src/core/employee/lifecycle.js +990 -0
  119. package/src/core/employee/memory/index.js +642 -0
  120. package/src/core/employee/memory/store.js +143 -0
  121. package/src/core/employee/performance.js +224 -0
  122. package/src/core/employee/secretary.js +625 -0
  123. package/src/core/employee/skills.js +398 -0
  124. package/src/core/index.js +38 -0
  125. package/src/core/organization/company.js +2600 -0
  126. package/src/core/organization/department.js +737 -0
  127. package/src/core/organization/group-chat-loop.js +264 -0
  128. package/src/core/organization/index.js +8 -0
  129. package/src/core/organization/persistence.js +111 -0
  130. package/src/core/organization/team.js +267 -0
  131. package/src/core/organization/workforce/hr.js +377 -0
  132. package/src/core/organization/workforce/providers.js +468 -0
  133. package/src/core/organization/workforce/role-archetypes.js +805 -0
  134. package/src/core/organization/workforce/talent-market.js +205 -0
  135. package/src/core/prompts.js +532 -0
  136. package/src/core/requirement.js +1789 -0
  137. package/src/core/system/audit.js +483 -0
  138. package/src/core/system/cron.js +449 -0
  139. package/src/core/system/index.js +7 -0
  140. package/src/core/system/plugin.js +2183 -0
  141. package/src/core/utils/json-parse.js +188 -0
  142. package/src/core/workspace.js +239 -0
  143. package/src/lib/api-i18n.js +211 -0
  144. package/src/lib/avatar.js +268 -0
  145. package/src/lib/client-store.js +1025 -0
  146. package/src/lib/config-validator.js +483 -0
  147. package/src/lib/format-time.js +22 -0
  148. package/src/lib/hooks.js +414 -0
  149. package/src/lib/i18n.js +134 -0
  150. package/src/lib/paths.js +23 -0
  151. package/src/lib/store.js +72 -0
  152. package/src/locales/de.js +393 -0
  153. package/src/locales/en.js +1054 -0
  154. package/src/locales/es.js +393 -0
  155. package/src/locales/fr.js +393 -0
  156. package/src/locales/ja.js +501 -0
  157. package/src/locales/ko.js +513 -0
  158. package/src/locales/zh.js +828 -0
  159. package/tailwind.config.mjs +11 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Robust JSON parser for LLM / Web Agent output.
3
+ *
4
+ * LLM(尤其是 ChatGPT web 版)返回的 "JSON" 经常包含各种脏字符:
5
+ * - Markdown 代码块包裹 (```json ... ```)
6
+ * - Smart quotes("" → "")
7
+ * - BOM / 零宽空格等不可见字符
8
+ * - JSON 前后有额外的文字说明
9
+ * - 值中未转义的换行符
10
+ * - 尾逗号 (trailing comma)
11
+ *
12
+ * 本模块提供统一的解析入口,避免在业务代码中重复编写 fallback 链。
13
+ */
14
+
15
+ /**
16
+ * 清理 LLM 输出中常见的脏字符,使其更容易被 JSON.parse 解析。
17
+ * @param {string} raw
18
+ * @returns {string}
19
+ */
20
+ function sanitize(raw) {
21
+ let s = raw;
22
+
23
+ // 1. 移除 BOM 和常见零宽字符
24
+ s = s.replace(/[\uFEFF\u200B\u200C\u200D\u2060]/g, '');
25
+
26
+ // 2. Smart quotes → ASCII quotes
27
+ s = s.replace(/[\u201C\u201D\u201E\u201F\u2033\u2036]/g, '"'); // ""„‟ → "
28
+ s = s.replace(/[\u2018\u2019\u201A\u201B\u2032\u2035]/g, "'"); // '' → '
29
+
30
+ // 3. 全角冒号 → 半角(JSON key-value 分隔符)
31
+ // 仅在明显的 JSON key 上下文中替换,避免误伤内容
32
+ // "key":value → "key":value
33
+ s = s.replace(/"(\s*):(\s*)/g, '"$1:$2');
34
+
35
+ return s;
36
+ }
37
+
38
+ /**
39
+ * 尝试从原始字符串中提取并解析 JSON 对象。
40
+ *
41
+ * 解析策略(按优先级):
42
+ * 1. 直接 JSON.parse
43
+ * 2. 去除 markdown code fence 后解析
44
+ * 3. 提取第一个 ```json ... ``` 或 ``` ... ``` 块
45
+ * 4. 提取最外层的 { ... } 子串
46
+ * 5. 修复尾逗号后重试
47
+ *
48
+ * @param {string} raw - LLM / Web Agent 的原始输出
49
+ * @param {object} [options]
50
+ * @param {boolean} [options.allowArray=false] - 是否允许顶层是数组
51
+ * @returns {object|Array} 解析后的 JSON 对象
52
+ * @throws {Error} 如果所有策略均失败
53
+ */
54
+ export function robustJSONParse(raw, options = {}) {
55
+ if (!raw || typeof raw !== 'string') {
56
+ throw new Error('robustJSONParse: input is empty or not a string');
57
+ }
58
+
59
+ const allowArray = options.allowArray ?? false;
60
+ const cleaned = sanitize(raw);
61
+
62
+ // 判断是否为有效的 JSON 值(对象,或可选的数组)
63
+ const isValid = (v) => {
64
+ if (v === null || v === undefined) return false;
65
+ if (typeof v === 'object' && !Array.isArray(v)) return true;
66
+ if (allowArray && Array.isArray(v)) return true;
67
+ return false;
68
+ };
69
+
70
+ // ── Strategy 1: 直接解析 ──
71
+ try {
72
+ const result = JSON.parse(cleaned);
73
+ if (isValid(result)) return result;
74
+ } catch {}
75
+
76
+ // ── Strategy 2: 去除外层 markdown fence ──
77
+ {
78
+ const tick = '`';
79
+ const fence = tick + tick + tick;
80
+ let stripped = cleaned.trim();
81
+ // 可能有多层 fence
82
+ for (let i = 0; i < 2; i++) {
83
+ if (stripped.startsWith(fence)) {
84
+ // 移除开头 fence(含可选的语言标签)和结尾 fence
85
+ stripped = stripped.replace(new RegExp('^' + fence.replace(/`/g, '\\`') + '[a-zA-Z]*\\s*\\n?'), '');
86
+ stripped = stripped.replace(new RegExp('\\n?\\s*' + fence.replace(/`/g, '\\`') + '\\s*$'), '');
87
+ }
88
+ }
89
+ if (stripped !== cleaned.trim()) {
90
+ try {
91
+ const result = JSON.parse(stripped.trim());
92
+ if (isValid(result)) return result;
93
+ } catch {}
94
+ }
95
+ }
96
+
97
+ // ── Strategy 3: 提取 code block 内容 ──
98
+ {
99
+ const codeBlockMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
100
+ if (codeBlockMatch) {
101
+ try {
102
+ const result = JSON.parse(codeBlockMatch[1].trim());
103
+ if (isValid(result)) return result;
104
+ } catch {}
105
+ // code block 内容可能也有脏字符,递归尝试提取 {...}
106
+ try {
107
+ const inner = codeBlockMatch[1].trim();
108
+ const start = inner.indexOf('{');
109
+ const end = inner.lastIndexOf('}');
110
+ if (start !== -1 && end > start) {
111
+ const result = JSON.parse(inner.substring(start, end + 1));
112
+ if (isValid(result)) return result;
113
+ }
114
+ } catch {}
115
+ }
116
+ }
117
+
118
+ // ── Strategy 4: 提取最外层 { ... } ──
119
+ {
120
+ const start = cleaned.indexOf('{');
121
+ const end = cleaned.lastIndexOf('}');
122
+ if (start !== -1 && end > start) {
123
+ const candidate = cleaned.substring(start, end + 1);
124
+ try {
125
+ const result = JSON.parse(candidate);
126
+ if (isValid(result)) return result;
127
+ } catch {}
128
+
129
+ // ── Strategy 5: 修复尾逗号 ──
130
+ try {
131
+ const fixed = candidate.replace(/,\s*([\]}])/g, '$1');
132
+ const result = JSON.parse(fixed);
133
+ if (isValid(result)) return result;
134
+ } catch {}
135
+
136
+ // ── Strategy 6: 修复值中未转义的换行 ──
137
+ try {
138
+ // 将 JSON 字符串值内的真实换行替换为 \\n
139
+ const fixedNewlines = candidate.replace(
140
+ /"(?:[^"\\]|\\.)*"/g,
141
+ (match) => match.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
142
+ );
143
+ const result = JSON.parse(fixedNewlines);
144
+ if (isValid(result)) return result;
145
+ } catch {}
146
+
147
+ // ── Strategy 7: 修复尾逗号 + 换行一起 ──
148
+ try {
149
+ let fixed = candidate.replace(/,\s*([\]}])/g, '$1');
150
+ fixed = fixed.replace(
151
+ /"(?:[^"\\]|\\.)*"/g,
152
+ (match) => match.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')
153
+ );
154
+ const result = JSON.parse(fixed);
155
+ if (isValid(result)) return result;
156
+ } catch {}
157
+ }
158
+ }
159
+
160
+ // ── Strategy 8: 允许数组时,提取 [ ... ] ──
161
+ if (allowArray) {
162
+ const start = cleaned.indexOf('[');
163
+ const end = cleaned.lastIndexOf(']');
164
+ if (start !== -1 && end > start) {
165
+ try {
166
+ const result = JSON.parse(cleaned.substring(start, end + 1));
167
+ if (Array.isArray(result)) return result;
168
+ } catch {}
169
+ }
170
+ }
171
+
172
+ // 全部失败
173
+ throw new Error(`robustJSONParse: Cannot extract valid JSON from LLM output (length=${raw.length}, preview="${raw.substring(0, 120)}")`);
174
+ }
175
+
176
+ /**
177
+ * 安全版本 — 解析失败不抛异常,返回 null。
178
+ * @param {string} raw
179
+ * @param {object} [options]
180
+ * @returns {object|Array|null}
181
+ */
182
+ export function safeJSONParse(raw, options = {}) {
183
+ try {
184
+ return robustJSONParse(raw, options);
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Workspace Manager
3
+ *
4
+ * Each department/project has its own independent working directory
5
+ * Agents perform file operations within their respective workspaces
6
+ */
7
+ import fs from 'fs/promises';
8
+ import { existsSync, mkdirSync } from 'fs';
9
+ import path from 'path';
10
+ import { WORKSPACE_DIR } from '../lib/paths.js';
11
+
12
+ export class WorkspaceManager {
13
+ constructor(rootDir = WORKSPACE_DIR) {
14
+ this.rootDir = rootDir;
15
+ if (!existsSync(rootDir)) {
16
+ mkdirSync(rootDir, { recursive: true });
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Create a workspace for a department
22
+ * @param {string} departmentId - Department ID
23
+ * @param {string} departmentName - Department name (used for directory naming)
24
+ * @returns {string} Workspace path
25
+ */
26
+ createDepartmentWorkspace(departmentId, departmentName) {
27
+ // Use a safe directory name
28
+ const safeName = departmentName.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_');
29
+ const dirName = `${safeName}_${departmentId.slice(0, 8)}`;
30
+ const wsPath = path.join(this.rootDir, dirName);
31
+
32
+ if (!existsSync(wsPath)) {
33
+ mkdirSync(wsPath, { recursive: true });
34
+ }
35
+
36
+ return wsPath;
37
+ }
38
+
39
+ /**
40
+ * Get the file tree under a department workspace
41
+ */
42
+ async getFileTree(wsPath, relativeTo = null) {
43
+ const basePath = relativeTo || wsPath;
44
+ const entries = [];
45
+
46
+ try {
47
+ const items = await fs.readdir(wsPath, { withFileTypes: true });
48
+ for (const item of items) {
49
+ const fullPath = path.join(wsPath, item.name);
50
+ const relPath = path.relative(basePath, fullPath);
51
+
52
+ if (item.isDirectory()) {
53
+ const children = await this.getFileTree(fullPath, basePath);
54
+ entries.push({
55
+ name: item.name,
56
+ path: relPath,
57
+ type: 'directory',
58
+ children,
59
+ });
60
+ } else {
61
+ const stat = await fs.stat(fullPath);
62
+ entries.push({
63
+ name: item.name,
64
+ path: relPath,
65
+ type: 'file',
66
+ size: stat.size,
67
+ modifiedAt: stat.mtime,
68
+ });
69
+ }
70
+ }
71
+ } catch (error) {
72
+ // Return empty if directory does not exist
73
+ }
74
+
75
+ return entries;
76
+ }
77
+
78
+ /**
79
+ * Get shallow (one-level) listing of a directory within the workspace
80
+ * @param {string} wsPath - Workspace root path
81
+ * @param {string} subPath - Relative sub-directory path (empty string for root)
82
+ */
83
+ async getShallowFileTree(wsPath, subPath = '') {
84
+ const targetDir = subPath ? path.join(wsPath, subPath) : wsPath;
85
+ const resolved = path.resolve(targetDir);
86
+
87
+ // Security check
88
+ if (!resolved.startsWith(path.resolve(wsPath))) {
89
+ throw new Error('Path is outside workspace boundary');
90
+ }
91
+
92
+ const entries = [];
93
+ try {
94
+ const items = await fs.readdir(targetDir, { withFileTypes: true });
95
+ for (const item of items) {
96
+ const fullPath = path.join(targetDir, item.name);
97
+ const relPath = path.relative(wsPath, fullPath);
98
+
99
+ if (item.isDirectory()) {
100
+ entries.push({
101
+ name: item.name,
102
+ path: relPath,
103
+ type: 'directory',
104
+ });
105
+ } else {
106
+ const stat = await fs.stat(fullPath);
107
+ entries.push({
108
+ name: item.name,
109
+ path: relPath,
110
+ type: 'file',
111
+ size: stat.size,
112
+ modifiedAt: stat.mtime,
113
+ });
114
+ }
115
+ }
116
+ } catch (error) {
117
+ // Return empty if directory does not exist
118
+ }
119
+
120
+ return entries;
121
+ }
122
+
123
+ /**
124
+ * Read a file within the workspace
125
+ * Automatically attempts to fix permission issues
126
+ */
127
+ async readFile(wsPath, filePath) {
128
+ const fullPath = path.join(wsPath, filePath);
129
+ const resolved = path.resolve(fullPath);
130
+
131
+ // Security check
132
+ if (!resolved.startsWith(path.resolve(wsPath))) {
133
+ throw new Error('Path is outside workspace boundary');
134
+ }
135
+
136
+ try {
137
+ return await fs.readFile(resolved, 'utf-8');
138
+ } catch (err) {
139
+ if (err.code === 'EACCES' || err.code === 'EPERM') {
140
+ // Auto-fix: try to grant read permission and retry
141
+ try {
142
+ await fs.chmod(resolved, 0o644);
143
+ console.log(`[workspace] Auto-fixed permission for: ${resolved}`);
144
+ return await fs.readFile(resolved, 'utf-8');
145
+ } catch (chmodErr) {
146
+ // chmod itself failed — re-throw with clear message
147
+ const error = new Error(
148
+ `Permission denied reading "${filePath}". Auto-fix failed. ` +
149
+ `Try running: chmod -R a+r "${path.resolve(wsPath)}"`
150
+ );
151
+ error.code = 'EACCES';
152
+ throw error;
153
+ }
154
+ }
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Get workspace statistics
161
+ */
162
+ async getStats(wsPath) {
163
+ let fileCount = 0;
164
+ let totalSize = 0;
165
+
166
+ async function walk(dir) {
167
+ try {
168
+ const items = await fs.readdir(dir, { withFileTypes: true });
169
+ for (const item of items) {
170
+ const fullPath = path.join(dir, item.name);
171
+ if (item.isDirectory()) {
172
+ await walk(fullPath);
173
+ } else {
174
+ fileCount++;
175
+ const stat = await fs.stat(fullPath);
176
+ totalSize += stat.size;
177
+ }
178
+ }
179
+ } catch { /* ignore */ }
180
+ }
181
+
182
+ await walk(wsPath);
183
+ return { fileCount, totalSize };
184
+ }
185
+
186
+ /**
187
+ * Take a snapshot of all files in a workspace (path → mtime mapping)
188
+ * Used for detecting file changes after CLI execution
189
+ * @param {string} wsPath - Workspace root path
190
+ * @returns {Map<string, number>} Map of relative path → mtime timestamp
191
+ */
192
+ async takeSnapshot(wsPath) {
193
+ const snapshot = new Map();
194
+ async function walk(dir) {
195
+ try {
196
+ const items = await fs.readdir(dir, { withFileTypes: true });
197
+ for (const item of items) {
198
+ const fullPath = path.join(dir, item.name);
199
+ if (item.isDirectory()) {
200
+ // Skip common non-project directories
201
+ if (item.name === 'node_modules' || item.name === '.git' || item.name === '__pycache__') continue;
202
+ await walk(fullPath);
203
+ } else {
204
+ const stat = await fs.stat(fullPath);
205
+ snapshot.set(path.relative(wsPath, fullPath), stat.mtimeMs);
206
+ }
207
+ }
208
+ } catch { /* ignore unreadable dirs */ }
209
+ }
210
+ await walk(wsPath);
211
+ return snapshot;
212
+ }
213
+
214
+ /**
215
+ * Diff two snapshots to find created/modified files
216
+ * @param {Map<string, number>} before - Snapshot before execution
217
+ * @param {Map<string, number>} after - Snapshot after execution
218
+ * @returns {{ created: string[], modified: string[] }}
219
+ */
220
+ diffSnapshots(before, after) {
221
+ const created = [];
222
+ const modified = [];
223
+ for (const [filePath, mtime] of after) {
224
+ if (!before.has(filePath)) {
225
+ created.push(filePath);
226
+ } else if (before.get(filePath) !== mtime) {
227
+ modified.push(filePath);
228
+ }
229
+ }
230
+ return { created, modified };
231
+ }
232
+
233
+ /**
234
+ * Get root workspace path
235
+ */
236
+ getRootDir() {
237
+ return this.rootDir;
238
+ }
239
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Server-side i18n utility for API routes.
3
+ *
4
+ * Reads the Accept-Language header (or a custom X-App-Lang header set by the
5
+ * frontend) and returns a translator function `t(key, params?)` that resolves
6
+ * keys from the same locale files used by the client.
7
+ *
8
+ * Usage in a route handler:
9
+ * import { getApiT } from '@/lib/api-i18n';
10
+ *
11
+ * export async function GET(request) {
12
+ * const t = getApiT(request);
13
+ * const company = getCompany();
14
+ * if (!company) return NextResponse.json({ error: t('api.noCompany') }, { status: 400 });
15
+ * ...
16
+ * }
17
+ *
18
+ * Keys live under the `api` namespace in every locale file.
19
+ *
20
+ * Edge case handling:
21
+ * - null / undefined request → falls back to DEFAULT_LANG ('en')
22
+ * - request without .headers → falls back to DEFAULT_LANG
23
+ * - empty, whitespace-only, or '*' X-App-Lang header → falls back to Accept-Language / DEFAULT_LANG
24
+ * - malformed Accept-Language value → skips invalid segments and falls back to DEFAULT_LANG
25
+ * - unsupported / unknown language → falls back to DEFAULT_LANG
26
+ * - missing translation key in lang → falls back to DEFAULT_LANG translation
27
+ * - missing translation key in DEFAULT_LANG → returns the key itself (graceful degradation)
28
+ * - getTForLang with invalid lang arg → falls back to DEFAULT_LANG
29
+ * - any exception in resolveLanguage → caught and DEFAULT_LANG returned
30
+ */
31
+
32
+ import en from '@/locales/en';
33
+ import zh from '@/locales/zh';
34
+ import ja from '@/locales/ja';
35
+ import ko from '@/locales/ko';
36
+ import es from '@/locales/es';
37
+ import fr from '@/locales/fr';
38
+ import de from '@/locales/de';
39
+
40
+ const translations = { en, zh, ja, ko, es, fr, de };
41
+ const DEFAULT_LANG = 'en';
42
+
43
+ /** Supported language codes */
44
+ const SUPPORTED = new Set(Object.keys(translations));
45
+
46
+ /**
47
+ * Sanitise a raw language tag token into a bare 2-3 letter code.
48
+ * Returns null when the token is empty, whitespace-only, or '*'.
49
+ *
50
+ * @param {string} raw e.g. "zh-CN", "en", "*", " ", ""
51
+ * @returns {string|null}
52
+ */
53
+ function sanitiseLangToken(raw) {
54
+ if (!raw || typeof raw !== 'string') return null;
55
+ const trimmed = raw.trim();
56
+ // '*' means "any language" — not useful for lookup
57
+ if (!trimmed || trimmed === '*') return null;
58
+ // Strip region tag: "zh-CN" → "zh", "en-US" → "en"
59
+ return trimmed.toLowerCase().split('-')[0] || null;
60
+ }
61
+
62
+ /**
63
+ * Parse the best matching language from an Accept-Language header value.
64
+ * Falls back to DEFAULT_LANG when nothing matches.
65
+ *
66
+ * Handles malformed input gracefully:
67
+ * - throws never; always returns a valid supported code
68
+ * - skips empty segments, bare quality-value tokens, and unknown codes
69
+ *
70
+ * @param {string|null} acceptLanguage Value of the Accept-Language header
71
+ * @param {string|null} appLang Value of the X-App-Lang header (higher priority)
72
+ * @returns {string} language code (always a member of SUPPORTED)
73
+ */
74
+ function resolveLanguage(acceptLanguage, appLang) {
75
+ try {
76
+ // Explicit app-lang header has the highest priority (sent by the SPA)
77
+ if (appLang) {
78
+ const code = sanitiseLangToken(appLang);
79
+ if (code && SUPPORTED.has(code)) return code;
80
+ // appLang present but invalid/unsupported — fall through to Accept-Language
81
+ }
82
+
83
+ if (!acceptLanguage || typeof acceptLanguage !== 'string') return DEFAULT_LANG;
84
+
85
+ // Parse Accept-Language: "zh-CN,zh;q=0.9,en;q=0.8"
86
+ // Segments are comma-separated; each may carry a quality value after ';'
87
+ const parts = acceptLanguage.split(',');
88
+ for (const part of parts) {
89
+ if (!part) continue;
90
+ // Strip the quality value: "zh-CN;q=0.9" → "zh-CN"
91
+ const [langTag] = part.trim().split(';');
92
+ const code = sanitiseLangToken(langTag);
93
+ if (code && SUPPORTED.has(code)) return code;
94
+ }
95
+ } catch (_) {
96
+ // Safety net: any unexpected error during header parsing → use default
97
+ }
98
+
99
+ return DEFAULT_LANG;
100
+ }
101
+
102
+ /**
103
+ * Resolve a dot-notated path inside a nested object.
104
+ * @param {object} obj
105
+ * @param {string} path e.g. "api.noCompany"
106
+ * @returns {string|undefined}
107
+ */
108
+ function getNestedValue(obj, path) {
109
+ if (!obj || typeof path !== 'string') return undefined;
110
+ return path.split('.').reduce((o, k) => (o != null && typeof o === 'object' ? o[k] : undefined), obj);
111
+ }
112
+
113
+ /**
114
+ * Create a translator function for the given language code.
115
+ *
116
+ * Resolution order:
117
+ * 1. translations[lang][key]
118
+ * 2. translations[DEFAULT_LANG][key] (en fallback)
119
+ * 3. key itself (last-resort graceful degradation)
120
+ *
121
+ * @param {string} lang
122
+ * @returns {(key: string, params?: Record<string, string|number>) => string}
123
+ */
124
+ function createTranslator(lang) {
125
+ // Guard: if somehow lang is not in SUPPORTED, default to 'en'
126
+ const resolvedLang = SUPPORTED.has(lang) ? lang : DEFAULT_LANG;
127
+
128
+ return function t(key, params) {
129
+ // Graceful key handling: non-string key returns empty string
130
+ if (typeof key !== 'string' || !key) return '';
131
+
132
+ let str = getNestedValue(translations[resolvedLang], key);
133
+
134
+ // Fallback 1: english translation
135
+ if (str == null || str === '') {
136
+ str = getNestedValue(translations[DEFAULT_LANG], key);
137
+ }
138
+
139
+ // Fallback 2: return the key itself so callers always get a non-empty string
140
+ if (str == null || str === '') {
141
+ str = key;
142
+ }
143
+
144
+ // Parameter substitution: replace {param} placeholders
145
+ if (params && typeof str === 'string' && typeof params === 'object') {
146
+ try {
147
+ Object.entries(params).forEach(([k, v]) => {
148
+ str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v ?? ''));
149
+ });
150
+ } catch (_) {
151
+ // If params substitution fails for any reason, return unsubstituted string
152
+ }
153
+ }
154
+
155
+ return typeof str === 'string' ? str : key;
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Build a translator from a Next.js Request object.
161
+ *
162
+ * Safe to call with null/undefined (returns English translator).
163
+ * Safe to call when request.headers is absent or non-standard.
164
+ *
165
+ * @param {Request|null|undefined} request Next.js request (may be null/undefined)
166
+ * @returns {(key: string, params?: object) => string}
167
+ */
168
+ export function getApiT(request) {
169
+ // No request (e.g. GET routes that don't receive a request arg)
170
+ if (!request) return createTranslator(DEFAULT_LANG);
171
+
172
+ try {
173
+ // Defensively read headers — request.headers may be absent or non-standard
174
+ const getHeader = request.headers?.get?.bind(request.headers);
175
+ const acceptLanguage = typeof getHeader === 'function'
176
+ ? (getHeader('accept-language') ?? null)
177
+ : null;
178
+ const appLang = typeof getHeader === 'function'
179
+ ? (getHeader('x-app-lang') ?? null)
180
+ : null;
181
+
182
+ const lang = resolveLanguage(acceptLanguage, appLang);
183
+ return createTranslator(lang);
184
+ } catch (_) {
185
+ // If anything unexpected happens during header reading, fall back to English
186
+ return createTranslator(DEFAULT_LANG);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Convenience: return translator for a specific language code directly.
192
+ * Useful in background/async contexts where the original Request is unavailable.
193
+ *
194
+ * Always safe — any invalid, null, undefined, or unsupported lang falls back to DEFAULT_LANG.
195
+ *
196
+ * @param {string|null|undefined} lang
197
+ * @returns {(key: string, params?: object) => string}
198
+ */
199
+ export function getTForLang(lang) {
200
+ // Sanitise: coerce to string, strip region tag, check support
201
+ let code = DEFAULT_LANG;
202
+ try {
203
+ const sanitised = sanitiseLangToken(String(lang ?? ''));
204
+ if (sanitised && SUPPORTED.has(sanitised)) {
205
+ code = sanitised;
206
+ }
207
+ } catch (_) {
208
+ // Fallback to DEFAULT_LANG on any error
209
+ }
210
+ return createTranslator(code);
211
+ }