瀏覽代碼

fix: restore integrated AI reading generation

彭峰 1 月之前
父節點
當前提交
330800a025

+ 5 - 4
README.md

@@ -23,10 +23,11 @@ npm run start:admin
 
 移动端运行在 `http://127.0.0.1:4200/`,管理后台运行在 `http://127.0.0.1:4300/admin/login`。两个开发服务器都通过 `proxy.conf.cjs` 转发所需服务,避免本地调试时的跨域问题。
 
-AI 阅读需要在启动进程中提供服务端环境变量,令牌不会进入浏览器构建产物:
+AI 阅读统一通过生产云函数调用生成服务,本地 Angular 进程不需要、也不应持有生成服务令牌。首次部署服务端配置时执行:
 
 ```bash
-FMODE_API_TOKEN="你的令牌" npm run start:mobile
+XIAOSHU_MASTER_KEY="..." FMODE_API_TOKEN="..." npm run reading:ai:configure
+npm run cloud:deploy -- --only=xiaoshu/app/gateway
 ```
 
 ## 构建与测试
@@ -71,10 +72,10 @@ npm run test:admin:ci
 ## 第三方数据边界
 
 - 客服迁移代码仅允许向 `uniapp.bytedesk.com`、`upload.bytedesk.com` 发送聊天内容和所选附件。当前匿名会话握手会因旧接口下线而中止,不会继续发送聊天或上传文件。
-- AI 阅读将手机号、昵称、NCloud 登录凭据、文章、题目、答案、成绩和历史记录发送至 `server.xiaoshu.pro`,并将单词列表、年级、题材、文章及提示词发送至 `api.fmode.cn`。AI 密钥与 Parse `masterKey` 只保留在服务端,不存放在前端源码或浏览器存储中。
+- AI 阅读的身份、文章、题目、答案、成绩和历史记录只由 `server.xiaoshu.pro` 的阅读云函数处理;生成所需的单词、年级、题材和提示词由该云函数转发至 `api.fmode.cn`。生成服务令牌与 Parse `masterKey` 只保留在服务端,不存放在前端源码、Angular 代理或浏览器存储中。
 - 上述流程已根据 2026-08-08 的明确授权启用。应用不会在该流程中自行向其他 NCloud 域名提交账号或密码。
 
-生产部署必须配置 `/fmode-api` 到 `https://api.fmode.cn` 的服务端反向代理并注入授权头,同时配置 `/ncloud` 到 `https://server.xiaoshu.pro/parse`;不要把 `FMODE_API_TOKEN` 或 Parse `masterKey` 写进 Angular 环境文件。
+生产部署需要配置 `xiaoshu/app/gateway` 云函数及 Parse Config 中的生成服务令牌;不要把 `FMODE_API_TOKEN` 或 Parse `masterKey` 写进 Angular 环境文件、反向代理或浏览器构建产物。
 
 为避免扩大私有数据读取范围,迁移版在两处保留了比源仓库更严格的限制:答题记录始终限定当前用户,PDF 页面继续要求当前应用登录;源仓库中未带用户条件的历史查询和公开 PDF ID 读取没有照搬。H5 目标也不再执行原 iframe 中仅供 APP-PLUS 使用的 `uni.postMessage` PDF 回传。
 

+ 2 - 0
package.json

@@ -25,6 +25,8 @@
     "app:schema:ensure": "node scripts/ensure-app-member-schemas.mjs",
     "app:resources:smoke": "node scripts/smoke-app-member-resources.mjs",
     "app:actions:audit": "node scripts/audit-app-cloud-actions.mjs",
+    "reading:prompts:sync": "node scripts/sync-reading-prompts.mjs",
+    "reading:ai:configure": "node scripts/configure-reading-ai.mjs",
     "sync-bridge:build": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj",
     "sync-bridge:publish": "dotnet publish legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release -r win-x64 --self-contained true -o dist/legacy-sync-bridge-win-x64",
     "sync-bridge:package": "npm run sync-bridge:publish && bash legacy-sync-bridge/deployment/package.sh",

+ 10 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -3,6 +3,7 @@ import { Subscription } from 'rxjs';
 import Mustache from 'mustache';
 import { FmodeAiService } from './fmode-ai.service';
 import { ReadingCloudService } from './reading-cloud.service';
+import { READING_PROMPT_FALLBACKS } from './reading-prompts';
 import {
   ReadingArticle, ReadingHistoryEntry, ReadingLog, ReadingOption, ReadingProgress,
   ReadingQuestion, ReadingRouteParams,
@@ -197,8 +198,14 @@ export class EnglishReadingService {
   }
 
   private async formatPrompt(code: string, values: Record<string, unknown>): Promise<string> {
-    const templates = await this.cloud.query<PromptRecord>('PromptTemplate', { code }, { limit: 1 });
-    const template = templates[0]?.template;
+    let template = '';
+    try {
+      const templates = await this.cloud.query<PromptRecord>('PromptTemplate', { code }, { limit: 1 });
+      template = templates[0]?.template ?? '';
+    } catch {
+      // The built-in prompt keeps reading available while migrated configuration is repaired.
+    }
+    template ||= READING_PROMPT_FALLBACKS[code] ?? '';
     if (!template) throw new Error(`未找到提示词模板:${code}`);
     return Mustache.render(template, values);
   }
@@ -212,7 +219,7 @@ export class EnglishReadingService {
         timer = setTimeout(() => {
           subscription?.unsubscribe();
           reject(new Error('生成服务响应超时'));
-        }, 40000);
+        }, 120000);
       };
       resetTimer();
       subscription = this.ai.stream(prompt).subscribe({

+ 47 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/fmode-ai.service.spec.ts

@@ -0,0 +1,47 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { API_CONFIG } from '../../core/app.constants';
+import { SessionService } from '../../core/session.service';
+import { AiStreamMessage, FmodeAiService } from './fmode-ai.service';
+
+describe('FmodeAiService', () => {
+  let service: FmodeAiService;
+  let http: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({ providers: [
+      provideHttpClient(),
+      provideHttpClientTesting(),
+      { provide: SessionService, useValue: { token: () => 'r:mobile-session' } },
+    ] });
+    service = TestBed.inject(FmodeAiService);
+    http = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => http.verify());
+
+  it('routes reading generation through the authenticated Xiaoshu cloud gateway', () => {
+    const received: AiStreamMessage[] = [];
+    service.stream('write an article').subscribe((value) => received.push(value));
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.headers.get('X-Parse-Application-Id')).toBe(API_CONFIG.parseAppId);
+    expect(request.request.headers.has('Authorization')).toBeFalse();
+    expect(request.request.body).toEqual({
+      token: 'r:mobile-session',
+      params: { action: 'reading/ai', prompt: 'write an article', jsonMode: false },
+    });
+    request.flush({ retcode: 0, result: { content: 'Reading article' } });
+    expect(received).toEqual([{ content: 'Reading article', complete: true }]);
+  });
+
+  it('requests structured JSON without exposing a provider credential', async () => {
+    const result = service.json('make questions', '{"questions":[]}');
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body.params.action).toBe('reading/ai');
+    expect(request.request.body.params.jsonMode).toBeTrue();
+    expect(request.request.body.params.prompt).toContain('make questions');
+    request.flush({ retcode: 0, result: { content: '{"questions":[]}' } });
+    expect(await result).toEqual({ questions: [] });
+  });
+});

+ 53 - 69
projects/xiaoshu-mobile/src/app/features/english-reading/fmode-ai.service.ts

@@ -1,21 +1,43 @@
+import { HttpClient, HttpHeaders } from '@angular/common/http';
 import { Injectable } from '@angular/core';
-import { Observable } from 'rxjs';
-
-const API_BASE = '/fmode-api';
-const MODEL = 'doubao-seed-2-0-lite-260215';
+import { firstValueFrom, Observable } from 'rxjs';
+import { API_CONFIG } from '../../core/app.constants';
+import { SessionService } from '../../core/session.service';
 
 export interface AiStreamMessage {
   content: string;
   complete: boolean;
 }
 
+interface CloudEnvelope<T> {
+  retcode: number;
+  retmsg?: string;
+  result: T;
+}
+
+interface AiResult {
+  content: string;
+}
+
 @Injectable({ providedIn: 'root' })
 export class FmodeAiService {
+  constructor(private readonly http: HttpClient, private readonly sessions: SessionService) {}
+
   stream(prompt: string): Observable<AiStreamMessage> {
     return new Observable((observer) => {
-      const controller = new AbortController();
-      void this.readStream(prompt, controller.signal, observer).catch((error: unknown) => observer.error(error));
-      return () => controller.abort();
+      const subscription = this.request(prompt, false).subscribe({
+        next: (response) => {
+          try {
+            const content = this.unwrap(response);
+            observer.next({ content, complete: true });
+            observer.complete();
+          } catch (error) {
+            observer.error(error);
+          }
+        },
+        error: (error) => observer.error(this.requestError(error)),
+      });
+      return () => subscription.unsubscribe();
     });
   }
 
@@ -23,72 +45,40 @@ export class FmodeAiService {
     const jsonPrompt = `${prompt}\n请严格按以下结构返回一个完整 JSON 对象,不要使用 Markdown 代码块:\n${schema}`;
     for (let attempt = 0; attempt <= retries; attempt += 1) {
       try {
-        const response = await fetch(`${API_BASE}/v1/chat/completions`, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({
-            model: MODEL,
-            messages: [{ role: 'user', content: jsonPrompt }],
-            stream: false,
-            max_tokens: 32768,
-            response_format: { type: 'json_object' },
-            thinking: { type: 'disabled' },
-          }),
-        });
-        if (!response.ok) throw new Error(await this.apiError(response));
-        const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
-        const content = data.choices?.[0]?.message?.content ?? '';
+        const response = await firstValueFrom(this.request(jsonPrompt, true));
+        const content = this.unwrap(response);
         onMessage?.(content);
         return this.extractJson(content);
       } catch (error) {
-        if (attempt === retries) throw error;
+        if (attempt === retries) throw this.requestError(error);
       }
     }
     throw new Error('题目生成失败');
   }
 
-  private async readStream(prompt: string, signal: AbortSignal, observer: {
-    next(value: AiStreamMessage): void; complete(): void; error(error: unknown): void;
-  }): Promise<void> {
-    const response = await fetch(`${API_BASE}/v1/chat/completions`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      signal,
-      body: JSON.stringify({
-        model: MODEL,
-        messages: [{ role: 'user', content: prompt }],
-        stream: true,
-        max_tokens: 32768,
-        thinking: { type: 'disabled' },
-      }),
+  private request(prompt: string, jsonMode: boolean): Observable<CloudEnvelope<AiResult>> {
+    const token = String(this.sessions.token() || '').trim();
+    if (!token.startsWith('r:')) throw new Error('阅读学习需要登录,请退出后重新登录');
+    return this.http.post<CloudEnvelope<AiResult>>(API_CONFIG.cloudFunctionUrl, {
+      token,
+      params: { action: 'reading/ai', prompt, jsonMode },
+    }, {
+      headers: new HttpHeaders({ 'X-Parse-Application-Id': API_CONFIG.parseAppId, 'Content-Type': 'application/json' }),
     });
-    if (!response.ok) throw new Error(await this.apiError(response));
-    if (!response.body) throw new Error('生成服务未返回内容');
+  }
 
-    const reader = response.body.getReader();
-    const decoder = new TextDecoder();
-    let buffer = '';
-    let content = '';
-    let done = false;
-    while (!done) {
-      const chunk = await reader.read();
-      done = chunk.done;
-      buffer += decoder.decode(chunk.value, { stream: !done });
-      const lines = buffer.split('\n');
-      buffer = lines.pop() ?? '';
-      for (const line of lines) {
-        if (!line.startsWith('data:')) continue;
-        const payload = line.slice(5).trim();
-        if (!payload || payload === '[DONE]') continue;
-        try {
-          const parsed = JSON.parse(payload) as { choices?: Array<{ delta?: { content?: string } }> };
-          content += parsed.choices?.[0]?.delta?.content ?? '';
-          observer.next({ content, complete: false });
-        } catch { /* Ignore malformed event data. */ }
-      }
-    }
-    observer.next({ content, complete: true });
-    observer.complete();
+  private unwrap(response: CloudEnvelope<AiResult>): string {
+    if (response.retcode === -1) throw new Error(response.retmsg || '阅读生成服务请求失败');
+    const content = String(response.result?.content || '');
+    if (!content.trim()) throw new Error('阅读生成服务未返回内容');
+    return content;
+  }
+
+  private requestError(error: unknown): Error {
+    const cloudMessage = (error as { error?: { retmsg?: string } } | null)?.error?.retmsg;
+    if (cloudMessage) return new Error(cloudMessage);
+    if (error instanceof Error && error.message) return error;
+    return new Error('阅读生成服务暂时不可用,请稍后重试');
   }
 
   private extractJson(content: string): Record<string, unknown> {
@@ -148,10 +138,4 @@ export class FmodeAiService {
     }
     return '';
   }
-
-  private async apiError(response: Response): Promise<string> {
-    const message = await response.text().catch(() => '');
-    if (response.status === 503 && message.includes('FMODE_API_TOKEN')) return '阅读生成服务尚未配置,请联系管理员';
-    return `生成服务请求失败(${response.status})`;
-  }
 }

+ 13 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-prompts.spec.ts

@@ -0,0 +1,13 @@
+import { ARTICLE_TEMPLATE, QUESTION_TEMPLATE, SUPPLY_TEMPLATE } from './english-reading.service';
+import { READING_PROMPT_FALLBACKS } from './reading-prompts';
+
+describe('reading prompt fallbacks', () => {
+  it('covers every prompt required by the migrated reading flow', () => {
+    for (const code of [ARTICLE_TEMPLATE, SUPPLY_TEMPLATE, QUESTION_TEMPLATE]) {
+      expect(READING_PROMPT_FALLBACKS[code].length).toBeGreaterThan(200);
+    }
+    expect(READING_PROMPT_FALLBACKS[ARTICLE_TEMPLATE]).toContain('{{#wordList}}');
+    expect(READING_PROMPT_FALLBACKS[SUPPLY_TEMPLATE]).toContain('{{{content}}}');
+    expect(READING_PROMPT_FALLBACKS[QUESTION_TEMPLATE]).toContain('{{count}}');
+  });
+});

+ 50 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-prompts.ts

@@ -0,0 +1,50 @@
+export const READING_PROMPT_FALLBACKS: Readonly<Record<string, string>> = {
+  'english-reading-article-tpl': `
+你是一名擅长中小学英语分级阅读的教学专家。请根据以下条件创作一篇自然、连贯、适合学生阅读的英文短文:
+- 年级难度:{{level}}
+- 学段:{{stage}}
+- 语篇类型:{{type}}
+- 内容题材:{{theme}}
+- 参考篇幅:约 {{count}} 个英文单词
+- 必须自然使用的目标生词:{{#wordList}}{{.}}, {{/wordList}}
+
+要求:
+1. 目标生词要融入语境,不得简单堆砌,并尽量全部出现。
+2. 语法、句式和词汇难度要符合 {{level}}。
+3. 内容健康、积极,不包含敏感、危险或不适合未成年人的内容。
+4. 第一行只输出英文标题;从第二行开始只输出英文正文。
+5. 正文分段输出,不要使用 Markdown 标题、代码块,不要附加解释。
+`.trim(),
+  'english-reading-article-supply-tpl': `
+你是英语分级阅读编辑。下面的英文短文没有完整包含指定生词,请在保持原文主题、情节、难度和语气不变的前提下自然改写。
+
+年级:{{level}}
+学段:{{stage}}
+必须补充的生词:{{#wordList}}{{.}}, {{/wordList}}
+
+原文:
+{{{content}}}
+
+要求:
+1. 每个缺失生词至少自然出现一次,不得堆砌。
+2. 保持原文的长度和阅读难度,必要时可小幅扩写。
+3. 只输出修订后的完整英文正文,不要输出标题、修改说明或 Markdown 代码块。
+`.trim(),
+  'english-reading-question-json-tpl': `
+你是一名中小学英语阅读理解命题老师。请根据以下英文文章,为 {{level}} 学生生成 {{count}} 道单项选择题。
+
+文章标题:{{title}}
+文章正文:
+{{{content}}}
+
+目标生词:{{#wordList}}{{.}}, {{/wordList}}
+单题难度限制:{{stageQuestionLimit}}
+
+要求:
+1. 题目要覆盖主旨、细节、词义或简单推理,不得超出文章信息。
+2. 题干和选项使用英文,每题至少有四个选项,且只有一个选项的 check 为 true。
+3. answer 字段使用简洁中文说明答案依据;每个选项的 answer 字段可说明其正误原因。
+4. wordList 填写该题实际涉及的目标生词。
+5. 只输出合法 JSON,不要使用 Markdown 代码块或附加文字。
+`.trim(),
+};

+ 0 - 7
proxy.conf.cjs

@@ -1,5 +1,3 @@
-const aiToken = process.env.FMODE_API_TOKEN || '';
-
 module.exports = {
   '/backend': {
     target: 'https://test.fmode.cn', secure: true, changeOrigin: true, logLevel: 'warn',
@@ -21,9 +19,4 @@ module.exports = {
     target: 'https://server.xiaoshu.pro', secure: true, changeOrigin: true, logLevel: 'warn',
     pathRewrite: { '^/cloud-functions': '/api/functions' },
   },
-  '/fmode-api': {
-    target: 'https://api.fmode.cn', secure: true, changeOrigin: true, logLevel: 'warn',
-    pathRewrite: { '^/fmode-api': '' },
-    headers: aiToken ? { Authorization: `Bearer ${aiToken}` } : {},
-  },
 };

+ 25 - 0
scripts/configure-reading-ai.mjs

@@ -0,0 +1,25 @@
+#!/usr/bin/env node
+
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = String(process.env.XIAOSHU_MASTER_KEY || '').trim();
+const AI_TOKEN = String(process.env.FMODE_API_TOKEN || '').trim();
+
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY,不会修改生产配置');
+if (!AI_TOKEN) throw new Error('缺少 FMODE_API_TOKEN,不会修改生产配置');
+
+const response = await fetch(`${PARSE_URL}/config`, {
+  method: 'PUT',
+  headers: {
+    'X-Parse-Application-Id': APP_ID,
+    'X-Parse-Master-Key': MASTER_KEY,
+    'Content-Type': 'application/json',
+  },
+  body: JSON.stringify({ params: { fmodeApiToken: AI_TOKEN } }),
+});
+const text = await response.text();
+let payload;
+try { payload = text ? JSON.parse(text) : {}; }
+catch { throw new Error(`Parse 配置接口返回无效 JSON(${response.status})`); }
+if (!response.ok || payload.error) throw new Error(payload.error || `Parse 配置失败(${response.status})`);
+console.log(JSON.stringify({ configured: true }, null, 2));

文件差異過大導致無法顯示
+ 8 - 0
scripts/deploy-admin-functions.mjs


+ 1 - 1
scripts/smoke-admin-functions.mjs

@@ -797,7 +797,7 @@ try {
   const sourceImplemented = implementedActions.filter((action) => sourceActionList.includes(action));
   const compatibilityImplemented = implementedActions.filter((action) => !sourceActionList.includes(action)).sort();
   if (sourceImplemented.length !== 75 || sourceBlocked.length !== 16) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
-  if (compatibilityImplemented.join(',') !== 'cart_add,content_add_zt,product_add,product_upd,reading/create,reading/get,reading/me,reading/query,reading/update,reading/user/update,upload') throw new Error(`app gateway 已实现兼容 action 计数异常:${compatibilityImplemented.join(',')}`);
+  if (compatibilityImplemented.join(',') !== 'cart_add,content_add_zt,product_add,product_upd,reading/ai,reading/create,reading/get,reading/me,reading/query,reading/update,reading/user/update,upload') throw new Error(`app gateway 已实现兼容 action 计数异常:${compatibilityImplemented.join(',')}`);
   if (compatibilityActions.length) throw new Error(`app gateway 阻塞兼容 action 应为空:${compatibilityActions.join(',')}`);
 
   const content = await callLegacyFunction('', { action: 'content_list', page: 1, pageSize: 2 });

+ 79 - 0
scripts/sync-reading-prompts.mjs

@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+
+const SOURCE_URL = (process.env.XIAOSHU_READING_PROMPT_SOURCE_URL || 'https://server.fmode.cn/parse').replace(/\/$/, '');
+const SOURCE_APP_ID = process.env.XIAOSHU_READING_PROMPT_SOURCE_APP_ID || 'ncloudmaster';
+const TARGET_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const TARGET_APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const commit = process.argv.includes('--commit');
+
+const CODES = [
+  'english-reading-article-tpl',
+  'english-reading-article-supply-tpl',
+  'english-reading-question-json-tpl',
+];
+
+if (commit && !MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY,不会写入生产数据');
+
+async function parseRequest(url, headers, init = {}) {
+  const response = await fetch(url, { ...init, headers: { ...headers, ...(init.headers || {}) } });
+  const text = await response.text();
+  let payload;
+  try { payload = text ? JSON.parse(text) : {}; }
+  catch { throw new Error(`Parse 返回了无效 JSON(${response.status})`); }
+  if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败(${response.status})`);
+  return payload;
+}
+
+function classQuery(baseUrl, where) {
+  return `${baseUrl}/classes/PromptTemplate?where=${encodeURIComponent(JSON.stringify(where))}&limit=100`;
+}
+
+const sourceHeaders = { 'X-Parse-Application-Id': SOURCE_APP_ID };
+const targetHeaders = {
+  'X-Parse-Application-Id': TARGET_APP_ID,
+  ...(MASTER_KEY ? { 'X-Parse-Master-Key': MASTER_KEY } : {}),
+  'Content-Type': 'application/json',
+};
+
+const sourcePayload = await parseRequest(classQuery(SOURCE_URL, { code: { $in: CODES } }), sourceHeaders);
+const sourceRows = Array.isArray(sourcePayload.results) ? sourcePayload.results : [];
+const sourceByCode = new Map(sourceRows.map((row) => [String(row.code || ''), row]));
+const missingSource = CODES.filter((code) => !sourceByCode.has(code));
+if (missingSource.length) throw new Error(`原系统缺少阅读模板:${missingSource.join(', ')}`);
+
+let targetRows = [];
+try {
+  const targetPayload = await parseRequest(classQuery(TARGET_URL, { code: { $in: CODES } }), targetHeaders);
+  targetRows = Array.isArray(targetPayload.results) ? targetPayload.results : [];
+} catch (error) {
+  if (!commit) throw error;
+  targetRows = [];
+}
+const targetByCode = new Map(targetRows.map((row) => [String(row.code || ''), row]));
+const summary = { source: sourceRows.length, targetBefore: targetRows.length, create: 0, update: 0, unchanged: 0, committed: commit };
+
+for (const code of CODES) {
+  const source = sourceByCode.get(code);
+  const target = targetByCode.get(code);
+  const value = {
+    code,
+    name: String(source.name || code),
+    template: String(source.template || ''),
+    ...(source.output === undefined ? {} : { output: source.output }),
+  };
+  const unchanged = target
+    && String(target.name || '') === value.name
+    && String(target.template || '') === value.template
+    && JSON.stringify(target.output ?? null) === JSON.stringify(value.output ?? null);
+  if (unchanged) { summary.unchanged += 1; continue; }
+  if (target) summary.update += 1;
+  else summary.create += 1;
+  if (!commit) continue;
+  const url = target
+    ? `${TARGET_URL}/classes/PromptTemplate/${encodeURIComponent(target.objectId)}`
+    : `${TARGET_URL}/classes/PromptTemplate`;
+  await parseRequest(url, targetHeaders, { method: target ? 'PUT' : 'POST', body: JSON.stringify(value) });
+}
+
+console.log(JSON.stringify(summary, null, 2));

部分文件因文件數量過多而無法顯示