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.
- package/.dockerignore +33 -0
- package/.nvmrc +1 -0
- package/ARCHITECTURE.md +394 -0
- package/Dockerfile +50 -0
- package/LICENSE +29 -0
- package/README.md +206 -0
- package/bin/i18n.js +46 -0
- package/bin/ideaco.js +494 -0
- package/deploy.sh +15 -0
- package/docker-compose.yml +30 -0
- package/electron/main.cjs +986 -0
- package/electron/preload.cjs +14 -0
- package/electron/web-backends.cjs +854 -0
- package/jsconfig.json +8 -0
- package/next.config.mjs +34 -0
- package/package.json +134 -0
- package/postcss.config.mjs +6 -0
- package/public/demo/dashboard.png +0 -0
- package/public/demo/employee.png +0 -0
- package/public/demo/messages.png +0 -0
- package/public/demo/office.png +0 -0
- package/public/demo/requirement.png +0 -0
- package/public/logo.jpeg +0 -0
- package/public/logo.png +0 -0
- package/scripts/prepare-electron.js +67 -0
- package/scripts/release.js +76 -0
- package/src/app/api/agents/[agentId]/chat/route.js +70 -0
- package/src/app/api/agents/[agentId]/conversations/route.js +35 -0
- package/src/app/api/agents/[agentId]/route.js +106 -0
- package/src/app/api/avatar/route.js +104 -0
- package/src/app/api/browse-dir/route.js +44 -0
- package/src/app/api/chat/route.js +265 -0
- package/src/app/api/company/factory-reset/route.js +43 -0
- package/src/app/api/company/route.js +82 -0
- package/src/app/api/departments/[deptId]/agents/[agentId]/dismiss/route.js +19 -0
- package/src/app/api/departments/route.js +92 -0
- package/src/app/api/group-chat-loop/events/route.js +70 -0
- package/src/app/api/group-chat-loop/route.js +94 -0
- package/src/app/api/mailbox/route.js +100 -0
- package/src/app/api/messages/route.js +14 -0
- package/src/app/api/providers/[id]/configure/route.js +21 -0
- package/src/app/api/providers/[id]/refresh-cookie/route.js +38 -0
- package/src/app/api/providers/[id]/test-cookie/route.js +28 -0
- package/src/app/api/providers/route.js +11 -0
- package/src/app/api/requirements/route.js +242 -0
- package/src/app/api/secretary/route.js +65 -0
- package/src/app/api/system/cli-backends/route.js +91 -0
- package/src/app/api/system/cron/route.js +110 -0
- package/src/app/api/system/knowledge/route.js +104 -0
- package/src/app/api/system/plugins/route.js +40 -0
- package/src/app/api/system/skills/route.js +46 -0
- package/src/app/api/system/status/route.js +46 -0
- package/src/app/api/talent-market/[profileId]/recall/route.js +22 -0
- package/src/app/api/talent-market/[profileId]/route.js +17 -0
- package/src/app/api/talent-market/route.js +26 -0
- package/src/app/api/teams/route.js +773 -0
- package/src/app/api/ws-files/[departmentId]/file/route.js +27 -0
- package/src/app/api/ws-files/[departmentId]/files/route.js +22 -0
- package/src/app/globals.css +130 -0
- package/src/app/layout.jsx +40 -0
- package/src/app/page.jsx +97 -0
- package/src/components/AgentChatModal.jsx +164 -0
- package/src/components/AgentDetailModal.jsx +425 -0
- package/src/components/AgentSpyModal.jsx +481 -0
- package/src/components/AvatarGrid.jsx +29 -0
- package/src/components/BossProfileModal.jsx +162 -0
- package/src/components/CachedAvatar.jsx +77 -0
- package/src/components/ChatPanel.jsx +219 -0
- package/src/components/ChatShared.jsx +255 -0
- package/src/components/DepartmentDetail.jsx +842 -0
- package/src/components/DepartmentView.jsx +367 -0
- package/src/components/FileReference.jsx +260 -0
- package/src/components/FilesView.jsx +465 -0
- package/src/components/GroupChatView.jsx +799 -0
- package/src/components/Mailbox.jsx +926 -0
- package/src/components/MessagesView.jsx +112 -0
- package/src/components/OnboardingGuide.jsx +209 -0
- package/src/components/OrgTree.jsx +151 -0
- package/src/components/Overview.jsx +391 -0
- package/src/components/PixelOffice.jsx +2281 -0
- package/src/components/ProviderGrid.jsx +551 -0
- package/src/components/ProvidersBoard.jsx +16 -0
- package/src/components/RequirementDetail.jsx +1279 -0
- package/src/components/RequirementsBoard.jsx +187 -0
- package/src/components/SecretarySettings.jsx +295 -0
- package/src/components/SetupWizard.jsx +388 -0
- package/src/components/Sidebar.jsx +169 -0
- package/src/components/SystemMonitor.jsx +808 -0
- package/src/components/TalentMarket.jsx +183 -0
- package/src/components/TeamDetail.jsx +697 -0
- package/src/core/agent/base-agent.js +104 -0
- package/src/core/agent/chat-store.js +602 -0
- package/src/core/agent/cli-agent/backends/claude-code/README.md +52 -0
- package/src/core/agent/cli-agent/backends/claude-code/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codebuddy/README.md +236 -0
- package/src/core/agent/cli-agent/backends/codebuddy/config.js +27 -0
- package/src/core/agent/cli-agent/backends/codex/README.md +51 -0
- package/src/core/agent/cli-agent/backends/codex/config.js +27 -0
- package/src/core/agent/cli-agent/backends/index.js +27 -0
- package/src/core/agent/cli-agent/backends/registry.js +580 -0
- package/src/core/agent/cli-agent/index.js +154 -0
- package/src/core/agent/index.js +60 -0
- package/src/core/agent/llm-agent/client.js +320 -0
- package/src/core/agent/llm-agent/index.js +97 -0
- package/src/core/agent/message-bus.js +211 -0
- package/src/core/agent/session.js +608 -0
- package/src/core/agent/tools.js +596 -0
- package/src/core/agent/web-agent/backends/base-backend.js +180 -0
- package/src/core/agent/web-agent/backends/chatgpt/client.js +146 -0
- package/src/core/agent/web-agent/backends/chatgpt/config.js +148 -0
- package/src/core/agent/web-agent/backends/chatgpt/dom-scripts.js +303 -0
- package/src/core/agent/web-agent/backends/index.js +91 -0
- package/src/core/agent/web-agent/index.js +278 -0
- package/src/core/agent/web-agent/web-client.js +407 -0
- package/src/core/employee/base-employee.js +1088 -0
- package/src/core/employee/index.js +35 -0
- package/src/core/employee/knowledge.js +327 -0
- package/src/core/employee/lifecycle.js +990 -0
- package/src/core/employee/memory/index.js +642 -0
- package/src/core/employee/memory/store.js +143 -0
- package/src/core/employee/performance.js +224 -0
- package/src/core/employee/secretary.js +625 -0
- package/src/core/employee/skills.js +398 -0
- package/src/core/index.js +38 -0
- package/src/core/organization/company.js +2600 -0
- package/src/core/organization/department.js +737 -0
- package/src/core/organization/group-chat-loop.js +264 -0
- package/src/core/organization/index.js +8 -0
- package/src/core/organization/persistence.js +111 -0
- package/src/core/organization/team.js +267 -0
- package/src/core/organization/workforce/hr.js +377 -0
- package/src/core/organization/workforce/providers.js +468 -0
- package/src/core/organization/workforce/role-archetypes.js +805 -0
- package/src/core/organization/workforce/talent-market.js +205 -0
- package/src/core/prompts.js +532 -0
- package/src/core/requirement.js +1789 -0
- package/src/core/system/audit.js +483 -0
- package/src/core/system/cron.js +449 -0
- package/src/core/system/index.js +7 -0
- package/src/core/system/plugin.js +2183 -0
- package/src/core/utils/json-parse.js +188 -0
- package/src/core/workspace.js +239 -0
- package/src/lib/api-i18n.js +211 -0
- package/src/lib/avatar.js +268 -0
- package/src/lib/client-store.js +1025 -0
- package/src/lib/config-validator.js +483 -0
- package/src/lib/format-time.js +22 -0
- package/src/lib/hooks.js +414 -0
- package/src/lib/i18n.js +134 -0
- package/src/lib/paths.js +23 -0
- package/src/lib/store.js +72 -0
- package/src/locales/de.js +393 -0
- package/src/locales/en.js +1054 -0
- package/src/locales/es.js +393 -0
- package/src/locales/fr.js +393 -0
- package/src/locales/ja.js +501 -0
- package/src/locales/ko.js +513 -0
- package/src/locales/zh.js +828 -0
- 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
|
+
}
|