czon 0.5.6 → 0.6.0

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  - **C**: **C**ontent oriented | 内容为王,专注内容
6
6
  - **Z**: **Z**ero Configuration | 零配置写作,减少打扰
7
7
  - **O**: **O**rganic AI-Native | 有机的 AI 原生,深度集成 AI
8
- - **N**: **N**-shape Energy Curve | N 型能量曲线,介入创作-分发-反馈的各个环节
8
+ - **N**: **N**-shaped Energy Curve | N 型能量曲线,介入创作-分发-反馈的各个环节
9
9
 
10
10
  [> Website Demo](https://czon.zccz14.com/)
11
11
 
@@ -26,9 +26,9 @@ AI 时代下,作为网站内容创作者,我们可以有更智能的内容
26
26
  ## 基本功能
27
27
 
28
28
  1. 🌍 **自动多语言翻译**:使用 AI 进行增量翻译,让用户使用母语编写 Markdown,但是用户可以是多语言的。
29
- 1. 💭 **自动摘要提取**:使用 AI 对原始文本进行内容分析和提取。
30
- 1. 🏷️ **自动标签分类**:使用 AI 对内容进行标签和分类的提取和管理。
31
- 1. 🧭 **智能分类导航**:使用 AI 生成站点地图和导航,源文件的位置不敏感。
29
+ 2. 💭 **自动摘要提取**:使用 AI 对原始文本进行内容分析和提取。
30
+ 3. 🏷️ **自动标签分类**:使用 AI 对内容进行标签和分类的提取和管理。
31
+ 4. 🧭 **智能分类导航**:使用 AI 生成站点地图和导航,源文件的位置不敏感。
32
32
 
33
33
  ## 静态站点生成 (SSG)
34
34
 
@@ -73,24 +73,35 @@ async function buildPipeline(options) {
73
73
  // 清理输出目录
74
74
  await fs.rm(paths_1.CZON_DIST_DIR, { recursive: true, force: true });
75
75
  // 确保 .czon/.gitignore 文件
76
- await (0, writeFile_1.writeFile)(path.join(paths_1.CZON_DIR, '.gitignore'), 'dist\n');
76
+ await (0, writeFile_1.writeFile)(path.join(paths_1.CZON_DIR, '.gitignore'), [
77
+ 'dist',
78
+ 'tmp',
79
+ // 忽略所有非 md 文件: 先忽略所有文件,再排除 Markdown 文件不忽略
80
+ 'src/**/*.*',
81
+ '!src/**/*.md',
82
+ ].join('\n'));
77
83
  // 扫描源文件
78
84
  await (0, scanSourceFiles_1.scanSourceFiles)();
79
- // 写入 .raw 目录用于存储原始文件 (非翻译文件)
85
+ // 链接资源文件 (非翻译文件)
80
86
  for (const file of metadata_1.MetaData.files) {
81
- try {
82
- if (!file.hash)
83
- throw new Error(`Missing hash`);
84
- const ext = path.extname(file.path);
85
- const targetPath = path.join(paths_1.CZON_DIST_RAW_CONTENT_DIR, file.hash + ext);
87
+ if (file.path.endsWith('.md'))
88
+ continue; // 仅处理非 Markdown 文件
89
+ for (const lang of metadata_1.MetaData.options.langs || []) {
90
+ // 创建硬链接以节省磁盘空间
91
+ const targetPath = path.join(paths_1.CZON_SRC_DIR, lang, file.path);
86
92
  const sourcePath = path.join(paths_1.INPUT_DIR, file.path);
87
- console.info(`💾 Writing raw content for file ${file.path} to ${targetPath} ...`);
88
- const content = await fs.readFile(sourcePath);
89
- await (0, writeFile_1.writeFile)(targetPath, content);
90
- }
91
- catch (error) {
92
- console.warn(`⚠️ Failed to write raw content for file ${file.path}:`, error);
93
+ console.info(`🔗 Linking file ${file.path} to ${targetPath} ...`);
94
+ // 确保 link 成功
95
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
96
+ await fs.rm(targetPath, { force: true });
97
+ await fs.link(sourcePath, targetPath);
93
98
  }
99
+ // 拷贝一份到 __raw__ 目录以供 dist 使用
100
+ const rawTargetPath = path.join(paths_1.CZON_DIST_RAW_CONTENT_DIR, file.path);
101
+ const rawSourcePath = path.join(paths_1.INPUT_DIR, file.path);
102
+ console.info(`📄 Copying raw file ${file.path} to ${rawTargetPath} ...`);
103
+ await fs.mkdir(path.dirname(rawTargetPath), { recursive: true });
104
+ await fs.copyFile(rawSourcePath, rawTargetPath);
94
105
  }
95
106
  // 运行 AI 元数据提取
96
107
  await (0, extractMetadataByAI_1.extractMetadataByAI)();
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.generateRobotsTxt = void 0;
37
37
  const path = __importStar(require("path"));
38
+ const metadata_1 = require("../metadata");
38
39
  const paths_1 = require("../paths");
39
40
  const writeFile_1 = require("../utils/writeFile");
40
41
  const generateRobotsTxt = async () => {
@@ -106,7 +107,7 @@ Disallow:
106
107
 
107
108
  Content-Signal: ai-train=yes, search=yes, ai-input=yes
108
109
 
109
- Sitemap: /sitemap.xml
110
+ ${metadata_1.MetaData.options.baseUrl ? `Sitemap: ${path.join(metadata_1.MetaData.options.baseUrl, 'sitemap.xml')}` : ''}
110
111
  `;
111
112
  const robotsPath = path.join(paths_1.CZON_DIST_DIR, 'robots.txt');
112
113
  await (0, writeFile_1.writeFile)(robotsPath, robotsTxtContent);
@@ -6,7 +6,6 @@ const openai_1 = require("../services/openai");
6
6
  const formatFileForCategoryExtraction = (file) => {
7
7
  return [
8
8
  //
9
- `Hash: ${file.hash}`,
10
9
  `Path: ${file.path}`,
11
10
  `Metadata: ${JSON.stringify(file.metadata)}`,
12
11
  ].join('\n');
@@ -19,14 +18,11 @@ const processExtractCategory = async () => {
19
18
  console.info('ℹ️ All files already have categories, skipping category extraction.');
20
19
  return;
21
20
  }
22
- // 这意味着,只要有一个新文件,就需要重新生成所有类别标签
23
- // 这倒也合理,因为类别标签是整体相关的
24
- // 如果是内容改动导致的呢?
25
21
  const markdownFiles = metadata_1.MetaData.files.filter(f => f.path.endsWith('.md') && f.metadata);
26
22
  if (verbose) {
27
23
  console.info(`📂 Extracting categories for ${markdownFiles.length} markdown files...`);
28
24
  for (const file of markdownFiles) {
29
- console.info(` - File: ${file.path} (hash: ${file.hash})`);
25
+ console.info(` - File: ${file.path}`);
30
26
  }
31
27
  }
32
28
  // 提取类别标签列表
@@ -46,7 +42,7 @@ const processExtractCategory = async () => {
46
42
  '请优先考虑尚未被分类的文件。',
47
43
  '请以 JSON 格式返回类别标签列表。',
48
44
  '示例输出格式:',
49
- '{ "mappings": { "hash1": "tag1", "hash2": "tag2" } }',
45
+ '{ "mappings": { "path1": "tag1", "path2": "tag2" } }',
50
46
  ].join('\n'),
51
47
  },
52
48
  {
@@ -66,9 +62,9 @@ const processExtractCategory = async () => {
66
62
  const json = categories.choices[0].message.content;
67
63
  const parsed = JSON.parse(json);
68
64
  for (const file of metadata_1.MetaData.files) {
69
- const hash = file.hash;
70
- if (parsed.mappings[hash]) {
71
- file.category = parsed.mappings[hash];
65
+ const path = file.path;
66
+ if (parsed.mappings[path]) {
67
+ file.category = parsed.mappings[path];
72
68
  }
73
69
  }
74
70
  console.info('✅ Extracted categories:', categories.choices[0].message.content);
@@ -10,25 +10,6 @@ const metadata_1 = require("../metadata");
10
10
  const paths_1 = require("../paths");
11
11
  const frontmatter_1 = require("../utils/frontmatter");
12
12
  const writeFile_1 = require("../utils/writeFile");
13
- const replaceInnerLinks = (file, markdownContent) => {
14
- let content = markdownContent;
15
- for (const link of file.links) {
16
- if (URL.canParse(link))
17
- continue; // 跳过绝对 URL
18
- const targetPath = path_1.default.resolve('/', path_1.default.dirname(file.path), link).slice(1);
19
- const targetFile = metadata_1.MetaData.files.find(f => f.path === targetPath);
20
- if (!targetFile) {
21
- console.warn(`⚠️ Link target not found for ${link} in file ${file.path}`);
22
- continue;
23
- }
24
- // 替换链接 (使用相对链接)
25
- const targetLink = `czon://${targetFile.hash}`;
26
- // 全局替换链接
27
- const linksRegex = new RegExp(`\\]\\(${link.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`, 'g');
28
- content = content.replace(linksRegex, `](${targetLink})`);
29
- }
30
- return content;
31
- };
32
13
  /**
33
14
  * 存储母语文件到 .czon/src
34
15
  */
@@ -41,11 +22,9 @@ async function storeNativeFiles() {
41
22
  continue;
42
23
  }
43
24
  try {
44
- if (!file.hash)
45
- throw new Error(`Missing hash`);
46
25
  if (!file.metadata?.inferred_lang)
47
26
  throw new Error(`Missing inferred language`);
48
- const filePath = path_1.default.join(paths_1.CZON_SRC_DIR, file.metadata.inferred_lang, file.hash + '.md');
27
+ const filePath = path_1.default.join(paths_1.CZON_SRC_DIR, file.metadata.inferred_lang, file.path);
49
28
  const originalContent = await (0, promises_1.readFile)(path_1.default.join(paths_1.INPUT_DIR, file.path), 'utf-8');
50
29
  // 增强 YAML Frontmatter
51
30
  const enhancedContent = (0, frontmatter_1.updateFrontmatter)(originalContent, {
@@ -55,8 +34,8 @@ async function storeNativeFiles() {
55
34
  date: file.metadata.inferred_date,
56
35
  });
57
36
  // 进行内链接替换, 将相对链接替换为基于 czon://hash 的链接
58
- const replacedContent = replaceInnerLinks(file, enhancedContent);
59
- await (0, writeFile_1.writeFile)(filePath, replacedContent);
37
+ // const replacedContent = replaceInnerLinks(file, enhancedContent);
38
+ await (0, writeFile_1.writeFile)(filePath, enhancedContent);
60
39
  }
61
40
  catch (error) {
62
41
  console.warn(`⚠️ Failed to store native file ${file.path}:`, error);
@@ -45,18 +45,46 @@ const sha256_1 = require("../utils/sha256");
45
45
  const writeFile_1 = require("../utils/writeFile");
46
46
  async function translateWithOpenCode(sourcePath, targetPath, targetLang, options) {
47
47
  const langName = languages_1.LANGUAGE_NAMES[targetLang];
48
- const prompt = `将 ${sourcePath} 翻译成 ${langName} (${targetLang}) 并保存到 ${targetPath}。`;
49
- await (0, opencode_1.runOpenCode)(prompt, {
50
- ...options,
51
- cwd: (0, path_1.dirname)(sourcePath),
52
- agent: 'czon-markdown-translator',
53
- });
54
- const exists = await (0, promises_1.access)(targetPath).then(() => true, () => false);
55
- if (!exists) {
56
- throw new Error(`OpenCode translation failed: ${targetPath} was not created`);
48
+ const taskId = crypto.randomUUID();
49
+ const COMMENT_FILE = (0, path_1.join)(paths_1.CZON_DIR, 'tmp', `comment-${taskId}.txt`);
50
+ // 清理旧的评论文件
51
+ await (0, writeFile_1.writeFile)(COMMENT_FILE, '');
52
+ // 任务定义:每个任务包含提示、代理名称和描述
53
+ const tasks = [
54
+ {
55
+ description: '翻译任务',
56
+ prompt: `将 ${sourcePath} 翻译成 ${langName} (${targetLang}) 并保存为 ${targetPath}。阅读 ${COMMENT_FILE} (如果存在) 以了解改进建议。`,
57
+ model: 'deepseek/deepseek-chat',
58
+ },
59
+ {
60
+ description: '翻译质量评估任务',
61
+ prompt: `请判断 ${targetPath} 是否是 ${sourcePath} 的优秀翻译。评估标准包括准确性、流畅性、术语一致性、文化适应性。输出评估结果和分数(0-10分)。将评审意见保存到 ${COMMENT_FILE} 文件中。如果通过评估,请写入 "Result: Passed"。否则写入 "Result: Failed" 并附上改进建议。`,
62
+ model: 'deepseek/deepseek-chat',
63
+ },
64
+ ];
65
+ for (let it = 0; it < 10; it++) {
66
+ for (let i = 0; i < tasks.length; i++) {
67
+ const task = tasks[i];
68
+ console.log(`\n=== 任务 ${i + 1}: ${task.description} ===`);
69
+ console.log(`提示: ${task.prompt}`);
70
+ await (0, opencode_1.runOpenCode)(task.prompt, { model: task.model });
71
+ console.log(`✅ 任务 ${i + 1} 完成`);
72
+ }
73
+ const reviewFileContent = await (0, promises_1.readFile)(COMMENT_FILE, 'utf-8').catch(() => '');
74
+ // 检查评审结果是否通过
75
+ if (reviewFileContent.includes('Result: Passed') &&
76
+ !reviewFileContent.includes('Result: Failed')) {
77
+ console.log('\n=== 翻译质量评估通过,所有任务完成 ===');
78
+ return;
79
+ }
57
80
  }
58
- return (0, promises_1.readFile)(targetPath, 'utf-8');
59
81
  }
82
+ const translateWithLLMCall = async (sourcePath, targetPath, lang) => {
83
+ const content = await (0, promises_1.readFile)(sourcePath, 'utf-8');
84
+ const translatedResponse = await (0, translateMarkdown_1.translateMarkdown)(sourcePath, content, lang);
85
+ const translatedContent = translatedResponse.choices?.[0].message.content?.trim() || '';
86
+ await (0, writeFile_1.writeFile)(targetPath, translatedContent);
87
+ };
60
88
  /**
61
89
  * 处理翻译
62
90
  */
@@ -69,7 +97,6 @@ async function processTranslations() {
69
97
  return;
70
98
  }
71
99
  return Promise.all(langs.map(async (lang) => {
72
- var _a;
73
100
  if (verbose)
74
101
  console.info(`📄 Processing file for translation: ${file.path}`);
75
102
  if (!file.metadata) {
@@ -79,8 +106,8 @@ async function processTranslations() {
79
106
  if (verbose)
80
107
  console.log(`🌐 Translating to ${lang}...`);
81
108
  // 存储翻译文件到 .czon/src/{lang}
82
- const sourcePath = path_1.default.join(paths_1.CZON_SRC_DIR, file.metadata.inferred_lang, file.hash + '.md'); // 使用已经加强的母语文件路径
83
- const targetPath = path_1.default.join(paths_1.CZON_SRC_DIR, lang, file.hash + '.md');
109
+ const sourcePath = path_1.default.join(paths_1.CZON_SRC_DIR, file.metadata.inferred_lang, file.path); // 使用已经加强的母语文件路径
110
+ const targetPath = path_1.default.join(paths_1.CZON_SRC_DIR, lang, file.path);
84
111
  try {
85
112
  const content = await (0, promises_1.readFile)(sourcePath, 'utf-8');
86
113
  if (file.metadata.inferred_lang === lang) {
@@ -95,12 +122,11 @@ async function processTranslations() {
95
122
  console.info(`ℹ️ Content unchanged for ${file.path}, skipping translation.`);
96
123
  return;
97
124
  }
98
- const translatedResponse = await (0, translateMarkdown_1.translateMarkdown)(sourcePath, content, lang);
99
- const translatedContent = translatedResponse.choices?.[0].message.content?.trim() || '';
100
- const translationMeta = ((_a = (file.translations ?? (file.translations = {})))[lang] ?? (_a[lang] = {}));
101
- translationMeta.content_length = translatedContent.length; // 记录翻译后内容长度
102
- translationMeta.token_used = translatedResponse.usage; // 记录 token 使用情况
103
- await (0, writeFile_1.writeFile)(targetPath, translatedContent);
125
+ await translateWithLLMCall(sourcePath, targetPath, lang);
126
+ // await translateWithOpenCode(sourcePath, targetPath, lang);
127
+ // const translationMeta = ((file.translations ??= {})[lang] ??= {});
128
+ // translationMeta.content_length = translatedContent.length; // 记录翻译后内容长度
129
+ // translationMeta.token_used = translatedResponse.usage; // 记录 token 使用情况
104
130
  // 存储已增强内容的哈希值
105
131
  file.nativeMarkdownHash = hash;
106
132
  if (verbose)
@@ -30,7 +30,7 @@ async function scanSourceFiles() {
30
30
  for (const filePath of markdownFiles) {
31
31
  queue.push(filePath);
32
32
  }
33
- const hashes = new Set();
33
+ const paths = new Set();
34
34
  while (queue.length > 0) {
35
35
  const relativePath = queue.shift();
36
36
  const fullPath = path_1.default.join(paths_1.INPUT_DIR, relativePath);
@@ -51,12 +51,15 @@ async function scanSourceFiles() {
51
51
  }
52
52
  const contentBuffer = await (0, promises_1.readFile)(fullPath);
53
53
  const hash = (0, sha256_1.sha256)(contentBuffer);
54
- hashes.add(hash);
55
- let meta = metadata_1.MetaData.files.find(f => f.hash === hash);
54
+ paths.add(relativePath);
55
+ let meta = metadata_1.MetaData.files.find(f => f.path === relativePath);
56
56
  if (!meta) {
57
57
  meta = { hash, path: relativePath, links: [] };
58
58
  metadata_1.MetaData.files.push(meta);
59
59
  }
60
+ else {
61
+ meta.hash = hash;
62
+ }
60
63
  // 处理 Markdown 文件
61
64
  if (fullPath.endsWith('.md')) {
62
65
  const content = contentBuffer.toString('utf-8');
@@ -76,7 +79,7 @@ async function scanSourceFiles() {
76
79
  }
77
80
  }
78
81
  // 移除不再存在的文件元数据
79
- metadata_1.MetaData.files = metadata_1.MetaData.files.filter(f => hashes.has(f.hash));
82
+ metadata_1.MetaData.files = metadata_1.MetaData.files.filter(f => paths.has(f.path));
80
83
  // 按路径降序排序 (通常外层目录优先)
81
84
  metadata_1.MetaData.files.sort((a, b) =>
82
85
  // 第一级按目录排序
@@ -56,17 +56,17 @@ const spiderStaticSiteGenerator = async () => {
56
56
  }
57
57
  const isVisited = new Set();
58
58
  const contents = [];
59
- // 预加载所有 Markdown 内容
59
+ // 预加载所有 Markdown 内容,因为 React 内部异步渲染比较麻烦
60
60
  for (const file of metadata_1.MetaData.files) {
61
61
  if (!file.path.endsWith('.md'))
62
62
  continue;
63
63
  for (const lang of metadata_1.MetaData.options.langs || []) {
64
- const markdown = await fs.readFile(path.join(paths_1.CZON_SRC_DIR, lang, file.hash + '.md'), 'utf-8');
64
+ const markdown = await fs.readFile(path.join(paths_1.CZON_SRC_DIR, lang, file.path), 'utf-8');
65
65
  const { frontmatter, body } = (0, frontmatter_1.parseFrontmatter)(markdown);
66
- const markdownHtml = (0, convertMarkdownToHtml_1.convertMarkdownToHtml)(body);
66
+ const markdownHtml = (0, convertMarkdownToHtml_1.convertMarkdownToHtml)(file.path, lang, body);
67
67
  contents.push({
68
68
  lang,
69
- hash: file.hash,
69
+ file,
70
70
  body: markdownHtml,
71
71
  frontmatter,
72
72
  });
@@ -83,42 +83,6 @@ const spiderStaticSiteGenerator = async () => {
83
83
  site: metadata_1.MetaData,
84
84
  contents,
85
85
  });
86
- // 内部链接: czon://hash 格式的链接替换为 /{lang}/{slug}.html
87
- html = html.replace(/href="([^"]+)"/g, (match, link) => {
88
- console.info(`🕷️ Processing link: ${link} in path: ${currentPath}`);
89
- if (link.startsWith('czon://')) {
90
- const hash = link.replace('czon://', '');
91
- console.info(` 🔗 Replacing internal link for hash: ${hash}`);
92
- const file = metadata_1.MetaData.files.find(f => f.hash === hash);
93
- if (!file || !file.metadata) {
94
- console.warn(`⚠️ Link target not found for hash ${hash} in path ${currentPath}`);
95
- return match;
96
- }
97
- const slug = file.metadata.slug;
98
- const targetPath = path.resolve('/', path.dirname(currentPath), `${slug}.html`);
99
- const href = path.relative(path.dirname(currentPath), targetPath);
100
- return `href="${href}"`;
101
- }
102
- return match;
103
- });
104
- // 替换 src 中的 czon://hash 链接
105
- html = html.replace(/src="([^"]+)"/g, (match, link) => {
106
- console.info(`🕷️ Processing src link: ${link} in path: ${currentPath}`);
107
- if (link.startsWith('czon://')) {
108
- const hash = link.replace('czon://', '');
109
- console.info(` 🔗 Replacing internal src link for hash: ${hash}`);
110
- const file = metadata_1.MetaData.files.find(f => f.hash === hash);
111
- if (!file) {
112
- console.warn(`⚠️ Src link target not found for hash ${hash} in path ${currentPath}`);
113
- return match;
114
- }
115
- const ext = path.extname(file.path);
116
- const targetPath = path.join(paths_1.CZON_DIST_RAW_CONTENT_DIR, file.hash + ext);
117
- const href = path.relative(path.join(paths_1.CZON_DIST_DIR, path.dirname(currentPath)), targetPath);
118
- return `src="${href}"`;
119
- }
120
- return match;
121
- });
122
86
  console.info(`🕷️ Crawled ${currentPath}`);
123
87
  // 收集 URL 用于 sitemap
124
88
  const urlMatch = currentPath.match(/^\/([^/]+)\/(.+)\.html$/);
@@ -143,7 +107,11 @@ const spiderStaticSiteGenerator = async () => {
143
107
  const link = match[1];
144
108
  if (URL.canParse(link))
145
109
  continue; // 跳过绝对 URL
110
+ if (link.startsWith('#'))
111
+ continue; // 跳过页面内锚点链接
146
112
  const resolvedPath = path.resolve('/', path.dirname(currentPath), link);
113
+ if (resolvedPath.startsWith('/__raw__/'))
114
+ continue; // 跳过原始内容目录
147
115
  console.info(` ➕ Found link: ${link} -> ${resolvedPath} (${isVisited.has(resolvedPath) ? 'visited' : 'new'})`);
148
116
  if (!isVisited.has(resolvedPath)) {
149
117
  queue.push(resolvedPath);
@@ -1,86 +1,125 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.installAgentsToGlobal = exports.runOpenCode = void 0;
4
- const child_process_1 = require("child_process");
5
4
  const promises_1 = require("fs/promises");
6
5
  const path_1 = require("path");
7
6
  const metadata_1 = require("../metadata");
8
7
  const paths_1 = require("../paths");
9
8
  const writeFile_1 = require("../utils/writeFile");
9
+ function parseModelString(model) {
10
+ const parts = model.split('/');
11
+ if (parts.length === 2) {
12
+ return { providerID: parts[0], modelID: parts[1] };
13
+ }
14
+ // Default provider if no slash
15
+ return { providerID: 'opencode', modelID: model };
16
+ }
10
17
  /**
11
- * Run OpenCode CLI to generate AI response for a given prompt.
18
+ * Run OpenCode to generate AI response for a given prompt.
12
19
  *
13
- * This function spawns the OpenCode CLI process and captures its output.
14
- * It serves as a fallback when OpenAI API is not available.
20
+ * This function uses the OpenCode SDK to connect to a running OpenCode server.
21
+ * Assumes an OpenCode server is already running externally.
15
22
  *
16
23
  * @param prompt - The prompt to send to OpenCode
17
24
  * @param options - Optional configuration
18
- * @returns Promise resolving to the raw AI response string
25
+ * @returns Promise that resolves when the operation completes
19
26
  *
20
27
  * @important
21
- * The returned string has **no guaranteed format**. It may be plain text, JSON,
22
- * Markdown, or any other format depending on the model and context.
23
- * Do NOT parse or assume a specific structure. Treat it as raw content to be
24
- * processed by downstream AI components (e.g., pass to another LLM call,
25
- * write to a temp file for a Supervisor Agent, etc.).
26
- *
27
- * @example
28
- * const response = await runOpenCode('Explain closures in JavaScript');
29
- * // response format is unpredictable - pass it to another AI for processing
30
- * const analyzed = await completeMessages([
31
- * { role: 'user', content: `Analyze this AI output:\n${response}` }
32
- * ]);
28
+ * The AI response is handled internally by OpenCode. This function does not
29
+ * return the response content. Any output files or results are managed by
30
+ * the OpenCode agent or session directly.
33
31
  */
34
32
  const runOpenCode = (prompt, options) => {
35
33
  const model = options?.model ?? 'opencode/gpt-5-nano';
36
34
  const signal = options?.signal;
37
- const cwd = options?.cwd;
35
+ const cwd = options?.cwd || process.cwd();
36
+ const agent = options?.agent;
38
37
  const verbose = metadata_1.MetaData.options.verbose;
39
38
  if (verbose) {
40
- console.info(`🛠️ Running OpenCode with model: ${model}, prompt: ${prompt}`);
39
+ console.info(`🛠️ Running OpenCode with model: ${model}, agent: ${agent || 'none'}, prompt: ${prompt}`);
41
40
  }
42
- return new Promise((resolve, reject) => {
43
- console.info(`🚀 Running OpenCode with model ${model}`);
44
- const proc = (0, child_process_1.spawn)('npx', [
45
- 'opencode-ai',
46
- 'run',
47
- prompt,
48
- '--model',
49
- model,
50
- ...(options?.agent ? ['--agent', options.agent] : []),
51
- '--format',
52
- 'json',
53
- ], {
54
- stdio: ['ignore', 'pipe', 'inherit'],
55
- cwd,
56
- env: Object.assign({
57
- OPENCODE_PERMISSION: JSON.stringify({ bash: 'allow', read: 'allow', write: 'allow' }),
58
- }, process.env),
59
- });
60
- let output = '';
61
- proc.stdout.on('data', data => {
62
- const chunk = data.toString();
63
- output += chunk;
64
- if (verbose) {
65
- console.info('OpenCode stdout chunk:', chunk);
66
- }
67
- });
68
- proc.on('error', err => {
69
- reject(new Error(`Failed to start OpenCode process: ${err.message}`));
70
- });
71
- proc.on('close', code => {
72
- if (code === 0) {
73
- resolve(output.trim());
41
+ return new Promise(async (resolve, reject) => {
42
+ const agentInfo = agent ? ` with agent ${agent}` : '';
43
+ console.info(`🚀 Running OpenCode with model ${model}${agentInfo}`);
44
+ let cancelled = false;
45
+ const cleanup = () => {
46
+ if (signal) {
47
+ signal.removeEventListener('abort', onAbort);
74
48
  }
75
- else {
76
- reject(new Error(`OpenCode process exited with code ${code}`));
77
- }
78
- });
49
+ };
50
+ const onAbort = () => {
51
+ cancelled = true;
52
+ cleanup();
53
+ reject(new Error('OpenCode execution was aborted'));
54
+ };
79
55
  if (signal) {
80
- signal.addEventListener('abort', () => {
81
- proc.kill('SIGTERM');
82
- reject(new Error('OpenCode execution was aborted'));
56
+ signal.addEventListener('abort', onAbort);
57
+ if (signal.aborted) {
58
+ onAbort();
59
+ return;
60
+ }
61
+ }
62
+ try {
63
+ const { createOpencodeClient } = await import('@opencode-ai/sdk');
64
+ const baseUrl = 'http://localhost:4096';
65
+ const client = createOpencodeClient({
66
+ baseUrl: baseUrl,
67
+ directory: cwd,
83
68
  });
69
+ const modelObj = parseModelString(model);
70
+ const session = await client.session.create();
71
+ if (!session.data?.id)
72
+ throw new Error('Failed to create OpenCode session', { cause: session.error });
73
+ options?.signal?.addEventListener('abort', () => {
74
+ console.info(`🛑 Aborting OpenCode session ${session.data.id}...`);
75
+ client.session.abort({
76
+ path: {
77
+ id: session.data.id,
78
+ },
79
+ });
80
+ });
81
+ const directoryBase64 = Buffer.from(session.data.directory).toString('base64');
82
+ const url = `${baseUrl}/${directoryBase64}/session/${session.data.id}`;
83
+ console.info('OpenCode Session Created', url);
84
+ const response = await client.session.prompt({
85
+ path: {
86
+ id: session.data.id,
87
+ },
88
+ body: {
89
+ model: modelObj,
90
+ agent,
91
+ parts: [
92
+ {
93
+ type: 'text',
94
+ text: prompt,
95
+ },
96
+ ],
97
+ },
98
+ query: {
99
+ directory: cwd,
100
+ },
101
+ signal,
102
+ });
103
+ if (cancelled) {
104
+ throw new Error('Cancelled');
105
+ }
106
+ if (response.error) {
107
+ throw new Error(`OpenCode API error: ${JSON.stringify(response.error)}`);
108
+ }
109
+ // await client.session.delete({
110
+ // path: {
111
+ // id: session.data.id,
112
+ // },
113
+ // });
114
+ cleanup();
115
+ resolve();
116
+ }
117
+ catch (err) {
118
+ if (cancelled) {
119
+ return;
120
+ }
121
+ cleanup();
122
+ reject(new Error(`OpenCode SDK error: ${err instanceof Error ? err.message : String(err)}. Make sure an OpenCode server is running.`));
84
123
  }
85
124
  });
86
125
  };
@@ -21,7 +21,7 @@ const ContentPage = props => {
21
21
  const date = frontmatter.date || '--';
22
22
  const tags = frontmatter.tags || [];
23
23
  const category = props.file.category;
24
- const relatedContents = props.ctx.site.files.filter(f => f.category === category && f.hash !== props.file.hash);
24
+ const relatedContents = props.ctx.site.files.filter(f => f.category === category && f.path !== props.file.path);
25
25
  // 查找指向当前文章的其他文章
26
26
  const thisPath = (0, node_path_1.resolve)('/', props.file.path);
27
27
  const referencedFiles = props.ctx.site.files.filter(f => f.links.some(link => (0, node_path_1.resolve)('/', (0, node_path_1.dirname)(f.path), link) === thisPath));
@@ -56,15 +56,15 @@ const ContentPage = props => {
56
56
  relatedContents.length > 0 && (react_1.default.createElement(react_1.default.Fragment, null,
57
57
  react_1.default.createElement("h2", null, "See Also"),
58
58
  react_1.default.createElement("ul", null, relatedContents.map(f => {
59
- const theContent = props.ctx.contents.find(c => c.lang === props.lang && c.hash === f.hash);
60
- return (react_1.default.createElement("li", { key: f.hash },
59
+ const theContent = props.ctx.contents.find(c => c.lang === props.lang && c.file === f);
60
+ return (react_1.default.createElement("li", { key: f.path },
61
61
  react_1.default.createElement("a", { href: `${f.metadata?.slug}.html` }, theContent?.frontmatter.title)));
62
62
  })))),
63
63
  referencedFiles.length > 0 && (react_1.default.createElement(react_1.default.Fragment, null,
64
64
  react_1.default.createElement("h2", null, "Referenced By"),
65
65
  react_1.default.createElement("ul", null, referencedFiles.map(f => {
66
- const theContent = props.ctx.contents.find(c => c.lang === props.lang && c.hash === f.hash);
67
- return (react_1.default.createElement("li", { key: f.hash },
66
+ const theContent = props.ctx.contents.find(c => c.lang === props.lang && c.file === f);
67
+ return (react_1.default.createElement("li", { key: f.path },
68
68
  react_1.default.createElement("a", { href: `${f.metadata?.slug}.html` }, theContent?.frontmatter.title)));
69
69
  }))))),
70
70
  react_1.default.createElement("footer", { className: "footer" },
@@ -64,8 +64,7 @@ const IndexPage = props => {
64
64
  ")"))));
65
65
  }))),
66
66
  react_1.default.createElement("div", null, contents.map(file => {
67
- const metadata = file.metadata;
68
- return (react_1.default.createElement("div", { className: "mb-6", key: file.hash },
67
+ return (react_1.default.createElement("div", { className: "mb-6", key: file.path },
69
68
  react_1.default.createElement(ContentMeta_1.ContentMeta, { ctx: props.ctx, file: file, lang: props.lang })));
70
69
  })),
71
70
  react_1.default.createElement("footer", null,
package/dist/ssg/app.js CHANGED
@@ -28,7 +28,7 @@ const App = (props) => {
28
28
  // 渲染文章页面
29
29
  for (const file of props.site.files) {
30
30
  if (props.path === `/${lang}/${file.metadata?.slug}.html`) {
31
- const theContent = props.contents.find(c => c.lang === lang && c.hash === file.hash);
31
+ const theContent = props.contents.find(c => c.lang === lang && c.file === file);
32
32
  if (!theContent)
33
33
  return react_1.default.createElement(RedirectPage_1.RedirectPage, { from: props.path, to: `/index.html` });
34
34
  return react_1.default.createElement(ContentPage_1.ContentPage, { ctx: props, file: file, lang: lang, content: theContent });
@@ -7,7 +7,7 @@ exports.ContentMeta = void 0;
7
7
  const react_1 = __importDefault(require("react"));
8
8
  const TagList_1 = require("./TagList");
9
9
  const ContentMeta = props => {
10
- const content = props.ctx.contents.find(c => c.hash === props.file.hash && c.lang === props.lang);
10
+ const content = props.ctx.contents.find(c => c.file === props.file && c.lang === props.lang);
11
11
  const frontmatter = content?.frontmatter || {};
12
12
  const title = frontmatter.title;
13
13
  const summary = frontmatter.summary;
@@ -22,10 +22,10 @@ const Navigator = props => {
22
22
  react_1.default.createElement("li", { className: "nav-item font-bold", key: categoryKey }, category || '--'),
23
23
  filesInCategory.map(file => {
24
24
  const link = file.metadata.slug + '.html';
25
- const isActive = props.file.hash === file.hash;
26
- const theContent = props.ctx.contents.find(c => c.lang === props.lang && c.hash === file.hash);
25
+ const isActive = props.file === file;
26
+ const theContent = props.ctx.contents.find(c => c.lang === props.lang && c.file === file);
27
27
  const theTitle = theContent?.frontmatter?.title || file.metadata.title || '(no title)';
28
- return (react_1.default.createElement("li", { className: "nav-item", key: file.hash },
28
+ return (react_1.default.createElement("li", { className: "nav-item", key: file.path },
29
29
  react_1.default.createElement("a", { href: link, className: `nav-link ${isActive ? 'active' : ''}` },
30
30
  theTitle,
31
31
  " ",
@@ -8,6 +8,8 @@ const highlight_js_1 = __importDefault(require("highlight.js"));
8
8
  const marked_1 = require("marked");
9
9
  const marked_katex_extension_1 = __importDefault(require("marked-katex-extension"));
10
10
  const marked_footnote_1 = __importDefault(require("marked-footnote"));
11
+ const metadata_1 = require("../metadata");
12
+ const path_1 = require("path");
11
13
  // 辅助函数:转义 HTML 特殊字符
12
14
  function escapeHtml(unsafe) {
13
15
  return unsafe
@@ -24,10 +26,67 @@ marked_1.marked.use((0, marked_footnote_1.default)());
24
26
  * @param mdContent Markdown 内容字符串
25
27
  * @returns 转换后的 HTML 字符串
26
28
  */
27
- const convertMarkdownToHtml = (mdContent) => {
29
+ const convertMarkdownToHtml = (path, lang, mdContent) => {
30
+ const sourceFileMeta = metadata_1.MetaData.files.find(f => f.path === path);
28
31
  // 创建自定义渲染器
29
32
  const renderer = new marked_1.marked.Renderer();
30
33
  const originalCodeRenderer = renderer.code;
34
+ const originalLinkRenderer = renderer.link;
35
+ renderer.link = function (link) {
36
+ console.info(`🔗 #### Processing link in Markdown: ${link.href} in file: ${path}`);
37
+ if (URL.canParse(link.href)) {
38
+ // 保持原有链接渲染行为
39
+ return originalLinkRenderer.call(this, link);
40
+ }
41
+ if (!sourceFileMeta?.metadata?.slug) {
42
+ console.warn(`⚠️ Source file metadata slug not found for path ${path}`);
43
+ return originalLinkRenderer.call(this, link);
44
+ }
45
+ const sourceFileHtmlPath = (0, path_1.resolve)('/', lang, `${sourceFileMeta.metadata.slug}.html`);
46
+ const resolvedPath = (0, path_1.join)((0, path_1.dirname)(path), link.href);
47
+ const file = metadata_1.MetaData.files.find(f => f.path === resolvedPath);
48
+ if (!file) {
49
+ console.warn(`⚠️ Link target not found for path ${resolvedPath} in file ${path}`);
50
+ return originalLinkRenderer.call(this, link);
51
+ }
52
+ if (link.href.endsWith('.md')) {
53
+ if (!file.metadata?.slug) {
54
+ console.warn(`⚠️ Missing slug metadata for file ${file.path}`);
55
+ return originalLinkRenderer.call(this, link);
56
+ }
57
+ const slug = file.metadata.slug;
58
+ const targetPath = (0, path_1.resolve)('/', lang, `${slug}.html`);
59
+ // 将 .md 链接转换为对应的 HTML 文件链接
60
+ const href = (0, path_1.relative)((0, path_1.dirname)(sourceFileHtmlPath), targetPath);
61
+ const modifiedLink = {
62
+ ...link,
63
+ href,
64
+ };
65
+ return originalLinkRenderer.call(this, modifiedLink);
66
+ }
67
+ else {
68
+ console.info(` 🔗 #### Processing resource link in Markdown: ${link.href} in file: ${path}`);
69
+ // 其他资源链接到 __raw__ 目录
70
+ const resourcePath = (0, path_1.resolve)('/', (0, path_1.dirname)(path), link.href);
71
+ const href = (0, path_1.relative)((0, path_1.dirname)(sourceFileHtmlPath), (0, path_1.join)('/', '__raw__', resourcePath));
72
+ console.info(` ➡️ Converted resource link to: ${href}`);
73
+ const modifiedLink = {
74
+ ...link,
75
+ href: href,
76
+ };
77
+ return originalLinkRenderer.call(this, modifiedLink);
78
+ }
79
+ };
80
+ const originalImageRenderer = renderer.image;
81
+ renderer.image = function (image) {
82
+ console.info(`🖼️ #### Processing image in Markdown: ${image.href} in file: ${path}`);
83
+ if (URL.canParse(image.href)) {
84
+ // 保持原有图片渲染行为
85
+ return originalImageRenderer.call(this, image);
86
+ }
87
+ const imagePath = (0, path_1.join)((0, path_1.dirname)(path), image.href);
88
+ return `<img src="${(0, path_1.join)('..', '__raw__', imagePath)}" alt="${image.text}" />`;
89
+ };
31
90
  // 重写代码块渲染器以支持 Mermaid - 使用 any 类型绕过类型检查
32
91
  renderer.code = function (code, language, isEscaped) {
33
92
  // 在 marked 17+ 中,code 参数是一个对象,包含 text 和 lang 属性
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "czon",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "CZone - AI enhanced Markdown content engine",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -51,6 +51,7 @@
51
51
  "typescript": "^5.9.3"
52
52
  },
53
53
  "dependencies": {
54
+ "@opencode-ai/sdk": "^1.1.34",
54
55
  "@types/react": "^19.2.7",
55
56
  "@types/react-dom": "^19.2.3",
56
57
  "chokidar": "^5.0.0",
package/tsconfig.json CHANGED
@@ -2,8 +2,9 @@
2
2
  "compilerOptions": {
3
3
  "jsx": "react",
4
4
  "target": "ES2020",
5
- "module": "commonjs",
6
- "lib": ["ES2020"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "lib": ["ES2020", "ESNext"],
7
8
  "outDir": "./dist",
8
9
  "rootDir": "./src",
9
10
  "strict": true,