diff --git a/.github/workflows/sync-griddb-to-drive.yml b/.github/workflows/sync-griddb-to-drive.yml new file mode 100644 index 00000000..9869e356 --- /dev/null +++ b/.github/workflows/sync-griddb-to-drive.yml @@ -0,0 +1,40 @@ +name: 🪞 Grid-DB → Google Drive 镜像同步 + +on: + push: + paths: + - 'grid-db/memory/**' + - 'grid-db/outbox/latest/**' + - 'grid-db/rules/**' + schedule: + - cron: '*/15 * * * *' + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync-to-drive: + if: github.actor != 'zhuyuan-bot' && !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 🔧 安装依赖 + run: npm install googleapis + + - name: 🪞 同步到 Google Drive + env: + GOOGLE_DRIVE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_DRIVE_SERVICE_ACCOUNT }} + DRIVE_FOLDER_ID: ${{ secrets.DRIVE_MIRROR_FOLDER_ID }} + run: node scripts/grid-db/sync-to-drive.js + + - name: 📋 写同步日志 + run: | + mkdir -p grid-db/logs + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | sync-to-drive | OK" >> grid-db/logs/drive-sync.log + git config user.name "zhuyuan-bot" + git config user.email "zhuyuan@guanghulab.com" + git add -f grid-db/logs/drive-sync.log + git diff --cached --quiet || git commit -m "log: Drive sync [skip ci]" + git push diff --git a/scripts/grid-db/DRIVE-BRIDGE-DEPLOY.md b/scripts/grid-db/DRIVE-BRIDGE-DEPLOY.md new file mode 100644 index 00000000..78a10f98 --- /dev/null +++ b/scripts/grid-db/DRIVE-BRIDGE-DEPLOY.md @@ -0,0 +1,114 @@ +# 🪞 Grid-DB ↔ Google Drive 桥接部署指南 + +> 指令编号: ZY-DRIVE-BRIDGE-2026-0323-001 +> +> 签发: 霜砚 · PER-SY001 +> +> 守护: 铸渊 · PER-ZY001 +> +> 系统: SYS-GLW-0001 + +--- + +## 📐 架构概述 + +Gemini 无法直接读写 GitHub 仓库文件。本方案通过 Google Drive 作为中转站,实现 Gemini 间接读写 `grid-db/`。 + +``` +仓库 grid-db/ ──GitHub Action──→ Drive mirror/ ──Gemini 读取──→ 用户看到 +用户说话 → Gemini 写入 → Drive inbox/ ──Apps Script──→ 仓库 grid-db/inbox/ → 铸渊处理 +``` + +**数据主权原则:Drive 里的东西全是「副本」。正本永远在仓库和 Notion。** + +--- + +## Phase A · 仓库 → Drive(让 Gemini 能读) + +### A.1 Google Cloud 配置 + +1. 前往 [Google Cloud Console](https://console.cloud.google.com) +2. 创建项目或使用已有项目 +3. 启用 **Google Drive API** +4. 创建 **Service Account**,下载 JSON 密钥文件 +5. 在用户的 Google Drive 中创建「光湖格点库」文件夹 +6. 将 Service Account 邮箱分享到该文件夹(**编辑者**权限) + +### A.2 配置 GitHub Secrets + +在仓库 Settings → Secrets 中添加: + +| Secret 名称 | 值 | +|---|---| +| `GOOGLE_DRIVE_SERVICE_ACCOUNT` | Service Account JSON 密钥的完整内容 | +| `DRIVE_MIRROR_FOLDER_ID` | Drive「光湖格点库」文件夹的 ID(URL 中最后一段) | + +### A.3 自动触发 + +Workflow `.github/workflows/sync-griddb-to-drive.yml` 会在以下情况自动运行: + +- `grid-db/memory/**` 文件变更时 +- `grid-db/outbox/latest/**` 文件变更时 +- `grid-db/rules/**` 文件变更时 +- 每 15 分钟定时同步 +- 手动触发(workflow_dispatch) + +### A.4 同步范围 + +| 仓库路径 | Drive 镜像路径 | 同步 | +|---|---|---| +| `grid-db/memory/DEV-XXX/` | `mirror/memory/DEV-XXX/` | ✅ | +| `grid-db/outbox/latest/` | `mirror/outbox/` | ✅ | +| `grid-db/rules/` | `mirror/rules/` | ✅ | +| `grid-db/interactions/` | — | ❌ 不同步 | +| `grid-db/training-lake/` | — | ❌ 不同步 | +| `grid-db/inbox/` | — | ❌ 不同步 | +| `grid-db/processing/` | — | ❌ 不同步 | + +--- + +## Phase B · Drive → 仓库(让 Gemini 能写) + +### B.1 部署 Apps Script + +1. 打开 [Google Apps Script](https://script.google.com) → 新建项目 +2. 项目名:`光湖格点库桥接器` +3. 将 `scripts/grid-db/drive-to-github-bridge.gs` 的内容粘贴到编辑器 +4. 项目设置 → 脚本属性 → 添加 `GITHUB_TOKEN`(需要 `repo` 权限的 PAT) +5. 触发器 → 添加: + - 函数:`processInbox` + - 事件源:时间驱动 + - 间隔:每 1 分钟 +6. 首次运行 → 授权 Drive 访问权限 + +### B.2 Drive 文件夹结构 + +Apps Script 会自动创建以下 Drive 文件夹结构: + +``` +光湖格点库/ +├── inbox/ ← Gemini 在这里创建新文档 +│ └── 已处理/ ← 成功写入 GitHub 后自动移入 +├── mirror/ ← GitHub Action 同步的只读镜像 +│ ├── memory/ +│ │ ├── DEV-001/ +│ │ ├── DEV-002/ +│ │ └── ... +│ ├── outbox/ +│ └── rules/ +└── logs/ ← 桥接器处理日志 +``` + +### B.3 Gemini 使用方式 + +Gemini 通过 Personal Context 读取 Drive `mirror/` 中的文件,通过在 `inbox/` 创建 Google Docs 来写入消息。用户全程只跟 Gemini 对话,无需感知 Drive 中转层。 + +--- + +## 安全注意事项 + +- **GITHUB_TOKEN** 必须存在 Apps Script 的 Script Properties 中,禁止硬编码 +- **Service Account** 密钥只存在 GitHub Secrets 中,不入库 +- Drive 中的所有文件都是副本,丢失可从仓库重新同步 +- Apps Script 只处理 `inbox/` 文件夹,不碰 `mirror/` +- 所有自动提交包含 `[skip ci]` 防止循环触发 diff --git a/scripts/grid-db/drive-to-github-bridge.gs b/scripts/grid-db/drive-to-github-bridge.gs new file mode 100644 index 00000000..c04ad6e1 --- /dev/null +++ b/scripts/grid-db/drive-to-github-bridge.gs @@ -0,0 +1,229 @@ +/** + * drive-to-github-bridge.gs + * + * Google Apps Script — 光湖格点库桥接器 + * + * 功能:监听 Drive「光湖格点库/inbox/」文件夹 + * 发现 Gemini 新建的文档 → 读取内容 → 通过 GitHub API 写入仓库 + * + * 触发方式:时间驱动触发器,每 1 分钟执行一次 + * + * 流程: + * ① 扫描 inbox 文件夹中所有文件 + * ② 读取文件内容(Google Docs → 提取纯文本/JSON) + * ③ 构造标准 inbox 消息格式 + * ④ 调用 GitHub Contents API 写入 grid-db/inbox/[filename].json + * ⑤ 写入成功 → 将 Drive 文件移到「已处理」文件夹(不删除,留审计轨迹) + * ⑥ 写入失败 → 标记文件,下次重试 + * + * 安全: + * - GitHub Token 存在 Script Properties 中(不硬编码) + * - 只处理 inbox 文件夹内的文件,不碰 mirror + * - 每次处理后写日志到 Drive「光湖格点库/logs/」 + * + * 部署步骤: + * 1. 打开 https://script.google.com → 新建项目 + * 2. 项目名:光湖格点库桥接器 + * 3. 粘贴此文件代码 + * 4. 项目设置 → 脚本属性 → 添加 GITHUB_TOKEN(需要 repo 权限) + * 5. 触发器 → 添加 → 函数 processInbox → 时间驱动 → 每 1 分钟 + * 6. 首次运行:点运行 → 授权 Drive 访问权限 + * + * 守护: PER-ZY001 铸渊 + * 系统: SYS-GLW-0001 + */ + +var CONFIG = { + INBOX_FOLDER_NAME: '光湖格点库/inbox', + PROCESSED_FOLDER_NAME: '光湖格点库/inbox/已处理', + LOG_FOLDER_NAME: '光湖格点库/logs', + GITHUB_REPO: 'qinfendebingshuo/guanghulab', + GITHUB_BRANCH: 'main', + GRID_DB_INBOX_PATH: 'grid-db/inbox/' +}; + +/** + * 主入口:处理 Drive inbox 中的新文件 + * 设置为时间驱动触发器,每 1 分钟执行 + */ +function processInbox() { + var token = PropertiesService.getScriptProperties() + .getProperty('GITHUB_TOKEN'); + if (!token) { + Logger.log('ERROR: GITHUB_TOKEN not set in Script Properties'); + return; + } + + var inboxFolder = getOrCreateFolder(CONFIG.INBOX_FOLDER_NAME); + var processedFolder = getOrCreateFolder(CONFIG.PROCESSED_FOLDER_NAME); + var logFolder = getOrCreateFolder(CONFIG.LOG_FOLDER_NAME); + + var files = inboxFolder.getFiles(); + var processedCount = 0; + var errorCount = 0; + + while (files.hasNext()) { + var file = files.next(); + try { + var content = extractContent(file); + if (!content) continue; + + // 生成带时间戳的文件名 + var timestamp = Utilities.formatDate( + new Date(), 'Asia/Shanghai', 'yyyyMMdd-HHmmss' + ); + var safeName = file.getName().replace(/[^a-zA-Z0-9\-_]/g, ''); + var filename = timestamp + '-' + safeName + '.json'; + + // 写入 GitHub 仓库 + var success = writeToGitHub( + token, + CONFIG.GRID_DB_INBOX_PATH + filename, + content + ); + + if (success) { + file.moveTo(processedFolder); + processedCount++; + Logger.log('OK: ' + filename); + } else { + errorCount++; + Logger.log('FAIL: ' + filename); + } + } catch (e) { + errorCount++; + Logger.log('ERROR: ' + file.getName() + ': ' + e.message); + } + } + + // 写处理日志 + if (processedCount > 0 || errorCount > 0) { + var logTimestamp = Utilities.formatDate( + new Date(), 'Asia/Shanghai', 'yyyy-MM-dd HH:mm:ss' + ); + var logLine = logTimestamp + ' | processed: ' + processedCount + + ' | errors: ' + errorCount + '\n'; + var logFileName = Utilities.formatDate( + new Date(), 'Asia/Shanghai', 'yyyyMMdd' + ) + '-bridge.log'; + + appendToLogFile(logFolder, logFileName, logLine); + } +} + +/** + * 从 Drive 文件中提取内容 + * 支持 Google Docs 和纯文本/JSON 文件 + */ +function extractContent(file) { + var mimeType = file.getMimeType(); + + if (mimeType === MimeType.GOOGLE_DOCS) { + // Google Docs → 提取纯文本 + var doc = DocumentApp.openById(file.getId()); + var text = doc.getBody().getText().trim(); + + // 尝试解析为 JSON(Gemini 可能直接写 JSON 格式) + try { + JSON.parse(text); + return text; + } catch (e) { + // 非 JSON → 包装为标准 inbox 消息格式 + return JSON.stringify({ + schema_version: '1.0', + message_id: Utilities.getUuid(), + timestamp: new Date().toISOString(), + source: 'gemini-via-drive', + type: 'raw-message', + priority: 'info', + payload: { + text: text + } + }, null, 2); + } + } else if (mimeType === MimeType.PLAIN_TEXT + || file.getName().endsWith('.json')) { + // 纯文本或 JSON 文件 → 直接读取 + return file.getBlob().getDataAsString(); + } + + Logger.log('SKIP: unsupported mime type ' + mimeType + + ' for file ' + file.getName()); + return null; +} + +/** + * 通过 GitHub Contents API 写入文件到仓库 + * @returns {boolean} 是否写入成功 + */ +function writeToGitHub(token, filePath, content) { + var url = 'https://api.github.com/repos/' + + CONFIG.GITHUB_REPO + '/contents/' + filePath; + + var payload = { + message: 'auto: Gemini inbox via Drive bridge [skip ci]', + content: Utilities.base64Encode(content, Utilities.Charset.UTF_8), + branch: CONFIG.GITHUB_BRANCH + }; + + var options = { + method: 'PUT', + headers: { + 'Authorization': 'Bearer ' + token, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + }, + contentType: 'application/json', + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + var response = UrlFetchApp.fetch(url, options); + var code = response.getResponseCode(); + + if (code !== 200 && code !== 201) { + Logger.log('GitHub API error: ' + code + ' ' + response.getContentText()); + } + + return (code === 200 || code === 201); +} + +/** + * 按路径逐级获取或创建 Drive 文件夹 + */ +function getOrCreateFolder(folderPath) { + var parts = folderPath.split('/'); + var folder = DriveApp.getRootFolder(); + + for (var i = 0; i < parts.length; i++) { + var folders = folder.getFoldersByName(parts[i]); + if (folders.hasNext()) { + folder = folders.next(); + } else { + folder = folder.createFolder(parts[i]); + } + } + return folder; +} + +/** + * 向日志文件追加内容(不存在则创建) + */ +function appendToLogFile(folder, fileName, content) { + var files = folder.getFilesByName(fileName); + if (files.hasNext()) { + var file = files.next(); + var existing = file.getBlob().getDataAsString(); + file.setContent(existing + content); + } else { + folder.createFile(fileName, content, MimeType.PLAIN_TEXT); + } +} + +// ═══════════════════════════════════════════════════════════ +// 触发器设置说明: +// Apps Script 编辑器 → 触发器 → 添加 +// 函数:processInbox +// 事件源:时间驱动 +// 间隔:每 1 分钟 +// ═══════════════════════════════════════════════════════════ diff --git a/scripts/grid-db/sync-to-drive.js b/scripts/grid-db/sync-to-drive.js new file mode 100644 index 00000000..0ffc37c6 --- /dev/null +++ b/scripts/grid-db/sync-to-drive.js @@ -0,0 +1,262 @@ +/** + * scripts/grid-db/sync-to-drive.js + * + * 功能:将 grid-db/ 中的关键文件同步到 Google Drive 镜像目录 + * + * 同步范围: + * grid-db/memory/DEV-XXX/*.json → Drive/mirror/memory/DEV-XXX/ + * grid-db/outbox/latest/*.json → Drive/mirror/outbox/ + * grid-db/rules/*.json → Drive/mirror/rules/ + * + * 同步策略: + * - 比较文件内容 MD5 hash,仅同步有变更的文件(节省 API 配额) + * - 文件不存在则创建,已存在则覆盖更新 + * - Drive 中多出的文件不删除(防误删) + * + * 注意: + * - 不同步 interactions/ 和 training-lake/(数据量大 + 无需 Gemini 读取) + * - 不同步 inbox/ 和 processing/(系统内部流转区) + * + * 环境变量: + * - GOOGLE_DRIVE_SERVICE_ACCOUNT: Service Account JSON 密钥内容 + * - DRIVE_FOLDER_ID: Drive 镜像根文件夹 ID + * + * 守护: PER-ZY001 铸渊 + * 系统: SYS-GLW-0001 + */ + +const { google } = require('googleapis'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const GRID_DB_ROOT = path.join(__dirname, '../../grid-db'); + +// 同步目录映射:本地相对路径 → Drive 镜像子目录 +const SYNC_DIRS = [ + { local: 'memory', drivePrefix: 'mirror/memory', recursive: true }, + { local: 'outbox/latest', drivePrefix: 'mirror/outbox', recursive: false }, + { local: 'rules', drivePrefix: 'mirror/rules', recursive: false } +]; + +/** + * 计算文件内容的 MD5 哈希 + */ +function md5(content) { + return crypto.createHash('md5').update(content).digest('hex'); +} + +/** + * 递归收集目录下的所有 JSON 文件 + */ +function collectFiles(dirPath, recursive) { + const results = []; + if (!fs.existsSync(dirPath)) return results; + + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory() && recursive) { + results.push(...collectFiles(fullPath, true)); + } else if (entry.isFile() && entry.name.endsWith('.json')) { + results.push(fullPath); + } + } + return results; +} + +/** + * 在 Drive 中查找或创建文件夹(按路径逐级创建) + */ +async function getOrCreateDriveFolder(drive, parentId, folderPath) { + const parts = folderPath.split('/').filter(Boolean); + let currentParentId = parentId; + + for (const part of parts) { + const query = `name='${part}' and '${currentParentId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`; + const res = await drive.files.list({ + q: query, + fields: 'files(id, name)', + pageSize: 1 + }); + + if (res.data.files && res.data.files.length > 0) { + currentParentId = res.data.files[0].id; + } else { + const createRes = await drive.files.create({ + requestBody: { + name: part, + mimeType: 'application/vnd.google-apps.folder', + parents: [currentParentId] + }, + fields: 'id' + }); + currentParentId = createRes.data.id; + console.log(`[sync-to-drive] Created folder: ${part}`); + } + } + + return currentParentId; +} + +/** + * 在 Drive 目标文件夹中查找同名文件 + */ +async function findFileInDrive(drive, folderId, fileName) { + const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`; + const res = await drive.files.list({ + q: query, + fields: 'files(id, name, md5Checksum)', + pageSize: 1 + }); + + if (res.data.files && res.data.files.length > 0) { + return res.data.files[0]; + } + return null; +} + +/** + * 上传或更新单个文件到 Drive + */ +async function uploadFile(drive, folderId, fileName, content) { + const existingFile = await findFileInDrive(drive, folderId, fileName); + const { Readable } = require('stream'); + + const media = { + mimeType: 'application/json', + body: Readable.from([content]) + }; + + if (existingFile) { + // 检查内容是否变更(Drive 对上传文件自动计算 MD5) + const localHash = md5(content); + if (existingFile.md5Checksum === localHash) { + return { action: 'skipped', fileName }; + } + + await drive.files.update({ + fileId: existingFile.id, + media + }); + return { action: 'updated', fileName }; + } else { + await drive.files.create({ + requestBody: { + name: fileName, + parents: [folderId] + }, + media, + fields: 'id' + }); + return { action: 'created', fileName }; + } +} + +/** + * 主同步流程 + */ +async function main() { + // 验证环境变量 + const serviceAccountJson = process.env.GOOGLE_DRIVE_SERVICE_ACCOUNT; + const rootFolderId = process.env.DRIVE_FOLDER_ID; + + if (!serviceAccountJson) { + console.log('[sync-to-drive] GOOGLE_DRIVE_SERVICE_ACCOUNT not set, skipping'); + return; + } + if (!rootFolderId) { + console.log('[sync-to-drive] DRIVE_FOLDER_ID not set, skipping'); + return; + } + + // 解析 Service Account 凭证 + let credentials; + try { + credentials = JSON.parse(serviceAccountJson); + } catch (err) { + console.error('[sync-to-drive] Failed to parse service account JSON:', err.message); + process.exit(1); + } + + // 认证 Google Drive API + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/drive'] + }); + + const drive = google.drive({ version: 'v3', auth }); + + console.log('[sync-to-drive] Starting Grid-DB → Drive mirror sync'); + + let totalCreated = 0; + let totalUpdated = 0; + let totalSkipped = 0; + let totalErrors = 0; + + // 遍历每个同步目录 + for (const syncDir of SYNC_DIRS) { + const localDir = path.join(GRID_DB_ROOT, syncDir.local); + const files = collectFiles(localDir, syncDir.recursive); + + if (files.length === 0) { + console.log(`[sync-to-drive] ${syncDir.local}: no files to sync`); + continue; + } + + console.log(`[sync-to-drive] ${syncDir.local}: found ${files.length} file(s)`); + + for (const filePath of files) { + try { + // 计算 Drive 中的目标路径 + const relativePath = path.relative(localDir, filePath); + const dirParts = path.dirname(relativePath); + const fileName = path.basename(filePath); + + // 构建 Drive 文件夹路径 + let driveFolderPath = syncDir.drivePrefix; + if (dirParts !== '.') { + driveFolderPath = `${syncDir.drivePrefix}/${dirParts}`; + } + + // 确保 Drive 文件夹存在 + const targetFolderId = await getOrCreateDriveFolder( + drive, rootFolderId, driveFolderPath + ); + + // 读取文件内容并上传 + const content = fs.readFileSync(filePath, 'utf8'); + const result = await uploadFile(drive, targetFolderId, fileName, content); + + if (result.action === 'created') { + totalCreated++; + console.log(`[sync-to-drive] + ${relativePath} (created)`); + } else if (result.action === 'updated') { + totalUpdated++; + console.log(`[sync-to-drive] ~ ${relativePath} (updated)`); + } else { + totalSkipped++; + } + } catch (err) { + totalErrors++; + console.error(`[sync-to-drive] ! ${filePath}: ${err.message}`); + } + } + } + + // 输出统计 + console.log('[sync-to-drive] Sync complete:'); + console.log(` Created: ${totalCreated}`); + console.log(` Updated: ${totalUpdated}`); + console.log(` Skipped (unchanged): ${totalSkipped}`); + console.log(` Errors: ${totalErrors}`); + + if (totalErrors > 0) { + process.exit(1); + } +} + +main().catch(err => { + console.error('[sync-to-drive] Fatal error:', err.message); + process.exit(1); +});