Merge pull request #3 from qinfendebingshuo/copilot/fix-api-recognition-issues

feat: GitHub→Notion reverse data bridge (Pipeline A & E) + live deploy fix
This commit is contained in:
qinfendebingshuo 2026-03-06 19:27:27 +08:00 committed by GitHub
commit 2082515225
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1098 additions and 52 deletions

View File

@ -0,0 +1,72 @@
name: 铸渊 · Bridge E · GitHub Changes → Notion
# 管道 Emain 分支有 push 或 PR 创建/关闭 → 变更摘要同步到 Notion「📋 GitHub 变更日志」数据库
#
# 依赖 Secrets仓库 Settings → Secrets and variables → Actions
# NOTION_TOKEN Notion 集成 token
#
# CHANGES_DB_ID 已内置默认值e740b77aa6bd4ac0a2e8a75f678fba98
# 如需覆盖,可在 Secrets 中添加 CHANGES_DB_ID
on:
push:
branches: [main]
pull_request:
types: [opened, closed]
jobs:
bridge-changes-to-notion:
name: 📡 Bridge E · GitHub Changes → Notion
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # 获取前一次 commit 以便 diff
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# ── commit push ──────────────────────────────────────
- name: 📡 同步 commit 变更到 Notion
if: github.event_name == 'push'
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
CHANGES_DB_ID: ${{ secrets.CHANGES_DB_ID }}
EVENT_TYPE: commit
COMMIT_SHA: ${{ github.sha }}
COMMIT_MSG: ${{ github.event.head_commit.message }}
COMMITTER: ${{ github.event.head_commit.author.name || github.actor }}
COMMIT_TIMESTAMP: ${{ github.event.head_commit.timestamp }}
BRANCH: ${{ github.ref_name }}
run: |
# git diff 输出换行分隔的变更文件列表(包含新增、修改、删除)
# COMMIT_TIMESTAMP/COMMITTER fallback: GitHub 表达式 || 在空字符串时也会取右侧值
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || true)
export CHANGED_FILES="$CHANGED"
node scripts/notion-bridge.js changes
# ── PR 事件 ───────────────────────────────────────────
- name: 📡 同步 PR 变更到 Notion
if: github.event_name == 'pull_request'
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
CHANGES_DB_ID: ${{ secrets.CHANGES_DB_ID }}
EVENT_TYPE: pr
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_ACTION: ${{ github.event.action }}
PR_MERGED: ${{ github.event.pull_request.merged }}
COMMITTER: ${{ github.event.pull_request.user.login }}
COMMIT_TIMESTAMP: ${{ github.event.pull_request.updated_at }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
COMMIT_MSG: ${{ github.event.pull_request.title }}
BRANCH: ${{ github.event.pull_request.head.ref }}
run: node scripts/notion-bridge.js changes

View File

@ -0,0 +1,40 @@
name: 铸渊 · Bridge A · SYSLOG → Notion
# 管道 Asyslog-inbox/ 有新文件 push → 同步到 Notion「📥 GitHub SYSLOG 收件箱」数据库
#
# 依赖 Secrets仓库 Settings → Secrets and variables → Actions
# NOTION_TOKEN Notion 集成 token
#
# SYSLOG_DB_ID 已内置默认值330ab17507d542c9bbb96d0749b41197
# 如需覆盖,可在 Secrets 中添加 SYSLOG_DB_ID
on:
push:
branches: [main]
paths:
- 'syslog-inbox/**'
- 'syslog-processed/**'
workflow_dispatch:
jobs:
bridge-syslog-to-notion:
name: 📥 Bridge A · SYSLOG → Notion
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: 🔗 同步 syslog-inbox 到 Notion
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
SYSLOG_DB_ID: ${{ secrets.SYSLOG_DB_ID }}
COMMIT_SHA: ${{ github.sha }}
run: node scripts/notion-bridge.js syslog

View File

@ -2,7 +2,9 @@ name: 🌀 部署铸渊聊天室 (GitHub Pages)
on:
push:
branches: [main]
branches:
- main
- copilot/** # Copilot Agent 功能分支可直接部署预览PR 合并后自动失效
paths:
- 'docs/**'
- '.github/brain/**'

130
.github/workflows/syslog-pipeline.yml vendored Normal file
View File

@ -0,0 +1,130 @@
name: 铸渊 · SYSLOG Pipeline (A/D/E)
on:
push:
branches: [main]
paths:
- 'syslog-inbox/**'
workflow_dispatch:
jobs:
# ── Pipeline ASYSLOG 读取与处理 ──────────────────────
pipeline-a-syslog:
name: 📥 Pipeline A · SYSLOG 读取处理
runs-on: ubuntu-latest
permissions:
contents: write
issues: read
outputs:
processed_count: ${{ steps.process.outputs.processed_count }}
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: 📥 处理 syslog-inbox 条目
id: process
run: |
node scripts/process-syslog.js
COUNT=$(ls syslog-processed/ -R 2>/dev/null | grep -c '\.json' || echo 0)
echo "processed_count=$COUNT" >> "$GITHUB_OUTPUT"
- name: 提交处理结果
run: |
git config user.name "铸渊[bot]"
git config user.email "zhu-yuan-bot@guanghulab.com"
git add syslog-inbox/ syslog-processed/ .github/brain/memory.json
git diff --staged --quiet || git commit -m "📥 syslog: 处理 inbox 条目,更新大脑记忆 [skip ci]"
git push
# ── Pipeline DIssues 巡检 ─────────────────────────────
pipeline-d-issues:
name: 🔍 Pipeline D · Issues 巡检
runs-on: ubuntu-latest
needs: pipeline-a-syslog
permissions:
contents: write
issues: read
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: 🔍 巡检 Issues
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
echo "🔍 Pipeline D · Issues 巡检开始"
node -e "
const https = require('https');
const token = process.env.GITHUB_TOKEN;
const repo = process.env.REPO;
const opts = {
hostname: 'api.github.com',
path: '/repos/' + repo + '/issues?state=open&per_page=20',
headers: { 'Authorization': 'Bearer ' + token, 'User-Agent': 'zhuyuan-bot' }
};
https.get(opts, res => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
const issues = JSON.parse(body);
console.log('📋 开放 Issues 数量:' + (Array.isArray(issues) ? issues.length : '读取失败'));
if (Array.isArray(issues) && issues.length > 0) {
issues.slice(0, 5).forEach(i => console.log(' #' + i.number + ' ' + i.title));
}
console.log('✅ Pipeline D 巡检完成');
});
}).on('error', e => { console.error('❌ 巡检失败:', e.message); process.exit(1); });
"
# ── Pipeline E变更感知 ────────────────────────────────
pipeline-e-changes:
name: 📡 Pipeline E · 变更感知
runs-on: ubuntu-latest
needs: pipeline-a-syslog
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: main
fetch-depth: 10
- name: 📡 感知近期变更
run: |
echo "📡 Pipeline E · 变更感知开始"
echo "=== 近期 10 次提交 ==="
git --no-pager log --oneline -10
echo ""
echo "=== syslog-inbox 文件状态 ==="
ls -la syslog-inbox/ || echo "(空)"
echo ""
echo "=== syslog-processed 文件状态 ==="
ls -laR syslog-processed/ || echo "(空)"
echo "✅ Pipeline E 变更感知完成"

View File

@ -6,10 +6,10 @@
**一句话:点下面这个链接就能打开,直接聊天。**
### 👉 [点这里打开铸渊聊天室](https://qinfendebingshuo.github.io/guanghulab/)
### 👉 [点这里打开铸渊聊天室](https://qinfendebingshuo.github.io/guanghulab/docs/index.html)
```
https://qinfendebingshuo.github.io/guanghulab/
https://qinfendebingshuo.github.io/guanghulab/docs/index.html
```
> 把这个链接发给任何人,他们打开就能用,不需要安装任何东西。
@ -20,7 +20,7 @@ https://qinfendebingshuo.github.io/guanghulab/
### ❄️ 冰朔(你)
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/docs/index.html
2. **选身份** → 下拉菜单选「❄️ 冰朔(语言架构师·创始人)」
3. **填 API 密钥** → 输入你的云雾/OpenAI/DeepSeek API Key
4. **点「开始对话」** → 铸渊自动唤醒,汇报大脑状态
@ -30,7 +30,7 @@ https://qinfendebingshuo.github.io/guanghulab/
### 🦁 肥猫(光湖团队总控 · DEV-002
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/docs/index.html
2. **选身份** → 下拉菜单选「🦁 肥猫光湖团队总控·DEV-002
3. **填 API 密钥** → 输入自己的 API Key或点「演示模式」也能看进度面板
4. **点「开始对话」** → 铸渊自动打开**总控指挥台**,显示全员进度
@ -44,7 +44,7 @@ https://qinfendebingshuo.github.io/guanghulab/
### 🍊 桔子(光湖主控 · DEV-010
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/docs/index.html
2. **选身份** → 下拉菜单选「🍊 桔子光湖主控·DEV-010
3. **填 API 密钥** → 输入自己的 API Key
4. **点「开始对话」** → 铸渊显示你当前模块进度,并开放指挥台
@ -53,7 +53,7 @@ https://qinfendebingshuo.github.io/guanghulab/
### 👩‍💻 其他开发者(页页 / 燕樊 / 小草莓 / 花尔 等)
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/
1. **打开链接** → https://qinfendebingshuo.github.io/guanghulab/docs/index.html
2. **选身份** → 下拉菜单选自己的名字
3. **填 API 密钥** → 填自己的 Key没有的话点「演示模式」
4. **点「开始对话」** → 铸渊显示你的当前任务状态,可以问问题
@ -80,7 +80,7 @@ https://qinfendebingshuo.github.io/guanghulab/
```
铸渊聊天室入口 👇
https://qinfendebingshuo.github.io/guanghulab/
https://qinfendebingshuo.github.io/guanghulab/docs/index.html
打开后选择你的身份,填入 API Key 就能和铸渊说话了。
```

View File

@ -1,8 +1,12 @@
<!DOCTYPE html>
<!-- 铸渊助手 v3.1 · 2026-03-06 · auto-detect API + Gemini + sessionStorage key -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>铸渊助手 · HoloLake AI</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
@ -136,6 +140,9 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
.sfg small{display:block;color:var(--dim);font-size:11px;margin-top:4px}
.sbtn2{width:100%;padding:10px;background:var(--accent);color:#fff;border:none;border-radius:var(--rs);font-size:14px;cursor:pointer;transition:opacity .2s;font-family:inherit}
.sbtn2:hover{opacity:.85}
.det-btn{flex-shrink:0;padding:0 12px;border-radius:var(--rs);border:1px solid var(--accent);background:transparent;color:var(--accent);font-size:12px;cursor:pointer;white-space:nowrap;transition:all .2s;font-family:inherit}
.det-btn:hover{background:rgba(79,142,247,.1)}
.det-btn:disabled{opacity:.5;cursor:default}
.sec-ttl{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:18px 0 10px;border-top:1px solid var(--border);padding-top:12px}
.ir2{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(30,50,81,.5);font-size:13px}
.iv{color:var(--ok);font-weight:500}
@ -218,12 +225,14 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
<div class="sc-logo">🌀</div>
<h1>铸渊助手</h1>
<p class="sub">HoloLake · 代码守护人格体 · 持续成长的 AI 伙伴</p>
<div class="sc-sec">🔒 API 密钥只保存在你的浏览器本地,不会上传至任何服务器</div>
<div class="sc-sec">🔒 API 密钥仅本次会话有效,关闭标签页即自动清除,不会持久保存在任何地方</div>
<div class="fg">
<label>选择 AI 提供商</label>
<select id="sp" onchange="onProv(this.value,'s')">
<option value="yunwu">☁️ 云雾 AI团队推荐</option>
<option value="openai">OpenAI (ChatGPT)</option>
<option value="gemini">🔮 Google Gemini</option>
<option value="deepseek">DeepSeek深度求索</option>
<option value="moonshot">MoonshotKimi</option>
<option value="zhipu">智谱 AIGLM</option>
@ -237,11 +246,17 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
<div class="fg">
<label>模型</label>
<select id="sm"></select>
<input type="text" id="sm-cust" placeholder="输入模型名称,如 gpt-4o、claude-3-5-sonnet-20241022、deepseek-chat" style="display:none;margin-top:6px">
<small id="sm-cust-hint" style="display:none;color:var(--dim);font-size:11px;margin-top:4px">自定义模式:直接输入你的服务商支持的任意模型名称</small>
</div>
<div class="fg">
<label>API 密钥</label>
<input type="password" id="sk" placeholder="sk-… 或对应格式的密钥">
<div style="display:flex;gap:6px;align-items:stretch">
<input type="password" id="sk" placeholder="粘贴任意 API 密钥,点击右侧按钮自动识别提供商和模型" style="flex:1">
<button onclick="autoDetectAPI('s')" id="sdet-btn" class="det-btn" title="自动检测提供商和可用模型">🔍 自动检测</button>
</div>
<small id="sk-err" style="color:var(--err);display:none">⚠️ 请输入 API 密钥</small>
<small id="sdet-status" style="display:block;margin-top:4px;min-height:16px"></small>
</div>
<div class="id-section">
@ -336,12 +351,20 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
<!-- ════════════════════ SETTINGS PANEL ════════════════════ -->
<div id="sp2" class="sp">
<div class="ph"><h2>⚙️ API 设置</h2><button class="cb" onclick="closeP()"></button></div>
<div class="ph">
<h2>⚙️ API 设置</h2>
<div style="display:flex;gap:6px;align-items:center">
<button onclick="resetAllSettings()" title="清除所有本地设置,重新开始" style="padding:4px 10px;border-radius:6px;border:1px solid #ef4444;background:transparent;color:#ef4444;font-size:11px;cursor:pointer;white-space:nowrap">🔄 重置</button>
<button class="cb" onclick="closeP()"></button>
</div>
</div>
<div class="pb">
<div class="sfg">
<label>AI 提供商</label>
<select id="cp" onchange="onProv(this.value,'c')">
<option value="yunwu">☁️ 云雾 AI团队推荐</option>
<option value="openai">OpenAI (ChatGPT)</option>
<option value="gemini">🔮 Google Gemini</option>
<option value="deepseek">DeepSeek深度求索</option>
<option value="moonshot">MoonshotKimi</option>
<option value="zhipu">智谱 AIGLM</option>
@ -355,11 +378,17 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
<div class="sfg">
<label>模型</label>
<select id="cm"></select>
<input type="text" id="cm-cust" placeholder="输入模型名称,如 gpt-4o、claude-3-5-sonnet-20241022、deepseek-chat" style="display:none;margin-top:6px">
<small id="cm-cust-hint" style="display:none;color:var(--dim);font-size:11px;margin-top:4px">自定义模式:直接输入你的服务商支持的任意模型名称</small>
</div>
<div class="sfg">
<label>API 密钥</label>
<input type="password" id="ck" placeholder="sk-…">
<small>🔒 只保存在浏览器 localStorage不上传任何服务器</small>
<div style="display:flex;gap:6px;align-items:stretch">
<input type="password" id="ck" placeholder="留空 = 保持当前密钥不变;粘贴新密钥即替换" style="flex:1">
<button onclick="autoDetectAPI('c')" id="cdet-btn" class="det-btn" title="自动检测提供商和可用模型">🔍 自动检测</button>
</div>
<small id="ck-hint" style="color:var(--dim)">🔒 密钥仅本次会话有效,关闭标签页自动清除</small>
<small id="cdet-status" style="display:block;margin-top:4px;min-height:14px"></small>
</div>
<button class="sbtn2" onclick="saveSet()">💾 保存设置</button>
@ -399,6 +428,10 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
4. 点 ⬇️ 下载单文件,放桌面直接打开使用<br>
5. 支持 OpenAI · DeepSeek · Kimi · 智谱 · 任意兼容接口
</p>
<div class="sec-ttl" style="color:#ef4444;margin-top:16px">⚠️ 危险操作</div>
<p style="font-size:12px;color:var(--dim);margin-bottom:8px">遇到密钥卡住、无法更新等问题时,点下方按钮彻底清除本地设置,从头重新输入。</p>
<button onclick="resetAllSettings()" style="width:100%;padding:10px;border-radius:8px;border:1px solid #ef4444;background:transparent;color:#ef4444;font-size:13px;cursor:pointer">🔄 清除所有设置,重新开始</button>
</div>
</div>
@ -426,7 +459,7 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
<ol class="help-steps">
<li data-n="1" id="helpUrlStep">打开链接 <strong>https://qinfendebingshuo.github.io/guanghulab/</strong></li>
<li data-n="2"><strong>下拉菜单选择你是谁</strong>(冰朔 / 肥猫 / 桔子 / 开发者名字)</li>
<li data-n="3">填入你的 <strong>API Key</strong>(没有的话点「演示模式」)</li>
<li data-n="3">填入你的 <strong>API Key</strong> 并点「🔍 自动检测」,系统将自动识别提供商和可用模型</li>
<li data-n="4">点「开始对话」,铸渊会针对你的身份打招呼</li>
<li data-n="5">直接用聊天的方式问铸渊任何问题</li>
</ol>
@ -435,8 +468,8 @@ footer{padding:12px 18px 16px;background:var(--s1);border-top:1px solid var(--bo
<div class="help-section">
<h3>🔑 从哪里获取 API Key</h3>
<div class="role-guide">
<div class="role-guide-title">推荐用云雾 API团队共用</div>
<p>找冰朔或肥猫获取团队 Key。填入后选「自定义」提供商,或直接用 DeepSeek</p>
<div class="role-guide-title">推荐用云雾 AI团队共用</div>
<p>找冰朔或肥猫获取团队 Key。填入后选「☁️ 云雾 AI团队推荐」提供商即可直接使用</p>
</div>
<div class="role-guide">
<div class="role-guide-title">没有 Key用演示模式</div>
@ -534,11 +567,13 @@ const ROLE_MAP = {
};
const PROVS = {
openai: {base:'https://api.openai.com/v1', mdls:['gpt-4o','gpt-4o-mini','gpt-4-turbo','gpt-3.5-turbo']},
deepseek: {base:'https://api.deepseek.com/v1', mdls:['deepseek-chat','deepseek-reasoner']},
moonshot: {base:'https://api.moonshot.cn/v1', mdls:['moonshot-v1-8k','moonshot-v1-32k','moonshot-v1-128k']},
zhipu: {base:'https://open.bigmodel.cn/api/paas/v4', mdls:['glm-4','glm-4-flash','glm-4-air']},
custom: {base:'', mdls:['gpt-4o']},
yunwu: {base:'https://api.yunwu.ai/v1', mdls:['gpt-4o','gpt-4o-mini','claude-3-5-sonnet-20241022','deepseek-chat','gemini-1.5-pro']},
openai: {base:'https://api.openai.com/v1', mdls:['gpt-4o','gpt-4o-mini','gpt-4-turbo','gpt-3.5-turbo']},
gemini: {base:'https://generativelanguage.googleapis.com/v1beta/openai', mdls:['gemini-2.0-flash','gemini-2.0-flash-lite','gemini-1.5-pro','gemini-1.5-flash']},
deepseek: {base:'https://api.deepseek.com/v1', mdls:['deepseek-chat','deepseek-reasoner']},
moonshot: {base:'https://api.moonshot.cn/v1', mdls:['moonshot-v1-8k','moonshot-v1-32k','moonshot-v1-128k']},
zhipu: {base:'https://open.bigmodel.cn/api/paas/v4', mdls:['glm-4','glm-4-flash','glm-4-air']},
custom: {base:'', mdls:['gpt-4o']},
};
const MODES = {
@ -549,7 +584,11 @@ const MODES = {
};
const MODE_ORD = ['chat','build','review','brain'];
const DEFAULT_MDL = 'gpt-4o';
const PROBE_TIMEOUT_MS = 8000; // ms per provider probe in autoDetectAPI
const FB_COV = {implemented:3,total:17,percent:'17.6%'};
// KEY_MASK is no longer used in the input field; kept only for backward compatibility
// with any saved value that might still be in localStorage.
const KEY_MASK = '(已保存)';
const FB_BRAIN = {
@ -562,11 +601,17 @@ const FB_BRAIN = {
// ═══════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════
// Derive base URL from saved provider; never use stale zy_base for named providers
const _initProv = ls('zy_prov')||'yunwu';
const _initBase = _initProv === 'custom'
? (ls('zy_base')||'')
: (PROVS[_initProv]?.base||'https://api.yunwu.ai/v1');
const A = {
key: ls('zy_key')||'',
base: ls('zy_base')||'https://api.openai.com/v1',
mdl: ls('zy_mdl')||'gpt-4o',
prov: ls('zy_prov')||'openai',
key: sessionStorage.getItem('zy_key')||'',
base: _initBase,
mdl: ls('zy_mdl')||DEFAULT_MDL,
prov: _initProv,
demo: false,
brain: null,
routing: null,
@ -585,10 +630,26 @@ const A = {
function ls(k){return localStorage.getItem(k)}
function lss(k,v){localStorage.setItem(k,v)}
const RESET_KEYS = ['zy_key','zy_base','zy_mdl','zy_prov','zy_uname','zy_ghuser','zy_role'];
// ═══════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════
async function boot(){
// URL-based reset: visiting the page with ?reset=1 clears all local settings.
// This is a reliable escape hatch when the UI cannot be navigated.
if(new URLSearchParams(location.search).get('reset')==='1'){
RESET_KEYS.forEach(k => localStorage.removeItem(k));
sessionStorage.removeItem('zy_key');
A.key=''; A.base=PROVS['yunwu'].base; A.mdl=DEFAULT_MDL; A.prov='yunwu'; A.demo=false;
A.userName=''; A.ghUser=''; A.role='guest';
// Remove ?reset=1 from the URL so a subsequent refresh does not trigger another reset.
history.replaceState(null,'',location.pathname);
}
// One-time migration: keys stored by old versions of this app in localStorage are
// no longer used (keys now live only in sessionStorage). Clear any stale value
// so it can never be accidentally picked up again.
localStorage.removeItem('zy_key');
initSetupUI();
// Restore identity from localStorage
if(A.userName) A.userMeta = ROLE_MAP[A.userName]||null;
@ -615,24 +676,41 @@ function showApp(){
// SETUP FORM
// ═══════════════════════════════════════════════════════
function initSetupUI(){
const pv = A.prov||'openai';
const pv = A.prov||'yunwu';
document.getElementById('sp').value = pv;
fillModels('sm', pv, A.mdl);
if(pv==='custom'){
const isCustom = pv==='custom';
document.getElementById('sm').style.display = isCustom?'none':'block';
document.getElementById('sm-cust').style.display = isCustom?'block':'none';
document.getElementById('sm-cust-hint').style.display= isCustom?'block':'none';
if(isCustom){
document.getElementById('sm-cust').value = A.mdl||DEFAULT_MDL;
document.getElementById('sep-g').style.display='block';
document.getElementById('sep').value = A.base;
} else {
fillModels('sm', pv, A.mdl);
}
}
function onProv(pv,ctx){
const mdlSel = ctx==='s'?'sm':'cm';
const epGrp = ctx==='s'?'sep-g':'cep-g';
const epInp = ctx==='s'?'sep':'cep';
const cur = ctx==='s'? A.mdl : document.getElementById('cm')?.value;
fillModels(mdlSel, pv, cur);
const show = pv==='custom';
document.getElementById(epGrp).style.display = show?'block':'none';
if(!show){
const mdlSel = ctx==='s'?'sm':'cm';
const mdlCust = ctx==='s'?'sm-cust':'cm-cust';
const mdlHint = ctx==='s'?'sm-cust-hint':'cm-cust-hint';
const epGrp = ctx==='s'?'sep-g':'cep-g';
const epInp = ctx==='s'?'sep':'cep';
const cur = ctx==='s'? A.mdl : document.getElementById('cm')?.value;
const isCustom = pv==='custom';
// Toggle model input: dropdown for known providers, text input for custom
document.getElementById(mdlSel).style.display = isCustom?'none':'block';
document.getElementById(mdlCust).style.display = isCustom?'block':'none';
document.getElementById(mdlHint).style.display = isCustom?'block':'none';
if(isCustom){
const custInp = document.getElementById(mdlCust);
if(custInp && !custInp.value) custInp.value = cur||DEFAULT_MDL;
} else {
fillModels(mdlSel, pv, cur);
}
document.getElementById(epGrp).style.display = isCustom?'block':'none';
if(!isCustom){
const ep = document.getElementById(epInp);
if(ep) ep.value = PROVS[pv]?.base||'';
}
@ -641,22 +719,110 @@ function onProv(pv,ctx){
function fillModels(selId, pv, cur){
const sel = document.getElementById(selId);
if(!sel) return;
const mdls = PROVS[pv]?.mdls||['gpt-4o'];
const mdls = PROVS[pv]?.mdls||[DEFAULT_MDL];
sel.innerHTML = mdls.map(m=>`<option value="${m}">${m}</option>`).join('');
if(cur && mdls.includes(cur)) sel.value=cur;
}
// ═══════════════════════════════════════════════════════
// AUTO-DETECT PROVIDER & MODELS
// ═══════════════════════════════════════════════════════
async function autoDetectAPI(ctx){
const keyEl = document.getElementById(ctx==='s'?'sk':'ck');
const statusEl = document.getElementById(ctx==='s'?'sdet-status':'cdet-status');
const btnEl = document.getElementById(ctx==='s'?'sdet-btn':'cdet-btn');
const key = keyEl.value.trim();
if(!key){
statusEl.innerHTML='<span style="color:var(--warn)">⚠️ 请先输入 API 密钥</span>';
return;
}
// Build probe list: custom endpoint first (if provided), then all named providers
const candidates = [];
const customBase = (document.getElementById(ctx==='s'?'sep':'cep')?.value||'').trim();
if(customBase) candidates.push({pv:'custom', base:customBase});
for(const [pv, cfg] of Object.entries(PROVS)){
if(pv!=='custom' && cfg.base) candidates.push({pv, base:cfg.base});
}
btnEl.disabled = true;
btnEl.textContent = '⏳ 检测中…';
statusEl.innerHTML = '<span style="color:var(--dim)">🔍 正在自动探测兼容提供商…</span>';
let matched = false;
for(const {pv, base} of candidates){
statusEl.innerHTML = `<span style="color:var(--dim)">🔍 探测 ${pv === 'custom' ? base : pv}…</span>`;
try{
const ctrl = new AbortController();
const tid = setTimeout(()=>ctrl.abort(), PROBE_TIMEOUT_MS);
const res = await fetch(base+'/models',{
headers:{'Authorization':'Bearer '+key},
signal: ctrl.signal,
});
clearTimeout(tid);
if(res.ok){
// Expected: { data: [ { id: "model-name", ... }, ... ] } (OpenAI-compatible /models)
const data = await res.json();
const mdls = (data.data||[]).map(m=>m.id).filter(Boolean).sort();
if(!mdls.length) continue;
// Switch provider select
const provSel = document.getElementById(ctx==='s'?'sp':'cp');
if(provSel) provSel.value = pv;
onProv(pv, ctx);
// Override model dropdown with live list (always show as dropdown)
const mdlSel = document.getElementById(ctx==='s'?'sm':'cm');
const mdlCust = document.getElementById(ctx==='s'?'sm-cust':'cm-cust');
const mdlHint = document.getElementById(ctx==='s'?'sm-cust-hint':'cm-cust-hint');
if(mdlSel){
mdlSel.innerHTML = mdls.map(m=>`<option value="${m}">${m}</option>`).join('');
mdlSel.style.display = 'block';
}
if(mdlCust) mdlCust.style.display='none';
if(mdlHint) mdlHint.style.display='none';
// If custom endpoint was the match, show the endpoint field
if(pv==='custom' && ctx==='s'){
document.getElementById('sep-g').style.display='block';
document.getElementById('sep').value = customBase;
} else if(pv==='custom' && ctx==='c'){
document.getElementById('cep-g').style.display='block';
document.getElementById('cep').value = customBase;
}
const label = PROVS[pv]?.base ? pv : customBase;
statusEl.innerHTML = `<span style="color:var(--ok)">✅ 检测成功(${label})· 共发现 <strong>${mdls.length}</strong> 个可用模型</span>`;
matched = true;
break;
}
}catch(e){ console.debug('autoDetect probe failed:', pv, e.message); }
}
if(!matched){
statusEl.innerHTML = '<span style="color:var(--err)">❌ 未能自动匹配,请手动选择提供商和模型后保存</span>';
}
btnEl.disabled = false;
btnEl.textContent = '🔍 自动检测';
}
function doSetup(){
const k = document.getElementById('sk').value.trim();
const errEl = document.getElementById('sk-err');
if(!k){errEl.style.display='block';return;}
errEl.style.display='none';
const pv = document.getElementById('sp').value;
const md = document.getElementById('sm').value;
const md = pv==='custom'
? ((document.getElementById('sm-cust')?.value||'').trim()||DEFAULT_MDL)
: document.getElementById('sm').value;
let base = PROVS[pv]?.base||'';
if(pv==='custom') base=(document.getElementById('sep')?.value||'').trim()||base;
A.key=k; A.base=base; A.mdl=md; A.prov=pv; A.demo=false;
lss('zy_key',k); lss('zy_base',base); lss('zy_mdl',md); lss('zy_prov',pv);
// Key is stored in sessionStorage only — it disappears when the tab/browser is closed.
// This prevents stale keys from persisting across sessions.
sessionStorage.setItem('zy_key', k); lss('zy_base',base); lss('zy_mdl',md); lss('zy_prov',pv);
// Save identity
const un = document.getElementById('suid').value;
const gh = (document.getElementById('sghuser')?.value||'').trim();
@ -676,13 +842,27 @@ function doDemo(){
// SETTINGS PANEL
// ═══════════════════════════════════════════════════════
function initSettingsPanel(){
const pv = A.prov||'openai';
const pv = A.prov||'yunwu';
document.getElementById('cp').value = pv;
fillModels('cm', pv, A.mdl);
document.getElementById('ck').value = A.key?KEY_MASK:'';
if(pv==='custom'){
const isCustom = pv==='custom';
document.getElementById('cm').style.display = isCustom?'none':'block';
document.getElementById('cm-cust').style.display = isCustom?'block':'none';
document.getElementById('cm-cust-hint').style.display= isCustom?'block':'none';
if(isCustom){
document.getElementById('cm-cust').value = A.mdl||DEFAULT_MDL;
document.getElementById('cep-g').style.display='block';
document.getElementById('cep').value = A.base;
} else {
fillModels('cm', pv, A.mdl);
}
// Always show EMPTY field — never prefill with a mask string.
// Empty on save = keep old key; any typed value = replace key.
document.getElementById('ck').value = '';
const hint = document.getElementById('ck-hint');
if(hint){
hint.textContent = A.key
? '🕐 密钥本次会话有效末4位…'+(A.key.length>=4?A.key.slice(-4):A.key)+')· 留空保持不变,输入新值即替换 · 关闭标签页自动清除'
: '🔒 密钥仅本次会话有效,关闭标签页自动清除,请输入后保存';
}
if(A.userName) document.getElementById('cuid').value=A.userName;
if(A.ghUser) document.getElementById('cghuser').value=A.ghUser;
@ -690,13 +870,19 @@ function initSettingsPanel(){
function saveSet(){
const pv = document.getElementById('cp').value;
const md = document.getElementById('cm').value;
const md = pv==='custom'
? ((document.getElementById('cm-cust')?.value||'').trim()||A.mdl||DEFAULT_MDL)
: document.getElementById('cm').value;
const raw = document.getElementById('ck').value.trim();
const k = raw===KEY_MASK? A.key : raw;
// Empty field = keep existing key; any non-empty input = use as new key.
// Never use KEY_MASK string comparison — that caused mixed-input bugs.
const k = raw || A.key;
let base = PROVS[pv]?.base||'';
if(pv==='custom') base=(document.getElementById('cep').value.trim())||base;
A.key=k; A.base=base; A.mdl=md; A.prov=pv; A.demo=!k;
lss('zy_key',k); lss('zy_base',base); lss('zy_mdl',md); lss('zy_prov',pv);
// Key lives in sessionStorage only — cleared when tab/browser closes.
if(k){ sessionStorage.setItem('zy_key', k); } else { sessionStorage.removeItem('zy_key'); }
lss('zy_base',base); lss('zy_mdl',md); lss('zy_prov',pv);
// Save identity
const un = document.getElementById('cuid')?.value||A.userName;
const gh = document.getElementById('cghuser')?.value?.trim()||A.ghUser;
@ -705,7 +891,18 @@ function saveSet(){
document.getElementById('bbdemo').style.display=A.demo?'inline':'none';
applyIdentityUI();
closeP();
botMsg('✅ 设置已保存。模型:<strong>'+esc(md)+'</strong>'+(un?' · 身份:'+esc(un):'')+(A.demo?'<br>⚠️ 未设置 API 密钥,演示模式仍然开启。':''));
const keyNote = raw ? ' · 🔑 新密钥已保存末4位…'+(k.length>=4?k.slice(-4):k)+'' : ' · 🔑 密钥未变更';
botMsg('✅ 设置已保存。模型:<strong>'+esc(md)+'</strong>'+keyNote+(un?' · 身份:'+esc(un):'')+(A.demo?'<br>⚠️ 未设置 API 密钥,演示模式仍然开启。':''));
}
// ═══════════════════════════════════════════════════════
// RESET ALL SETTINGS
// ═══════════════════════════════════════════════════════
function resetAllSettings(){
if(!confirm('确定要清除所有本地设置吗?\n\n· API 密钥会被删除\n· 身份设置会被删除\n· 将返回初始设置界面\n\n这不会影响任何聊天记录或云端数据。')) return;
RESET_KEYS.forEach(k => localStorage.removeItem(k));
sessionStorage.removeItem('zy_key');
window.location.reload();
}
// ═══════════════════════════════════════════════════════
@ -913,10 +1110,13 @@ async function streamReply(txt){
}),
});
if(!res.ok){
const provName=PROVS[A.prov]?.base||A.base;
let em=provName+' 返回错误 '+res.status;
const provLabel = A.prov==='custom' ? A.base : (A.prov+'('+A.base+')');
let em='['+provLabel+'] 返回错误 '+res.status;
let isAuthErr = (res.status===401||res.status===403);
try{const e=await res.json();em=e.error?.message||em;}catch(_){}
throw new Error(em);
const err = new Error(em);
err.isAuth = isAuthErr;
throw err;
}
let full='';
const rdr=res.body.getReader();
@ -942,7 +1142,17 @@ async function streamReply(txt){
A.msgs.push({role:'assistant',content:full});
addCopyBtns(el);
}catch(e){
el.innerHTML='<span class="err">⚠️ '+esc(e.message)+'</span><br><small style="color:var(--dim)">请检查 API 密钥和网络连接,或在 ⚙️ 设置中更换模型。</small>';
if(e.isAuth){
el.innerHTML='<span class="err">🔑 API 密钥认证失败</span>'
+'<br><small style="color:var(--dim)">'+esc(e.message)+'</small>'
+'<br><div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">'
+'<button onclick="openPanel(\'sp2\')" style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:13px">⚙️ 更新 API 密钥</button>'
+'<button onclick="resetAllSettings()" style="padding:6px 14px;border-radius:6px;border:1px solid #ef4444;background:transparent;color:#ef4444;cursor:pointer;font-size:13px">🔄 清除重置</button>'
+'</div>'
+'<small style="color:var(--dim);display:block;margin-top:6px">打开设置 → 直接输入新密钥 → 保存(无需清空,留空 = 保留旧密钥)</small>';
} else {
el.innerHTML='<span class="err">⚠️ '+esc(e.message)+'</span><br><small style="color:var(--dim)">请检查 API 密钥和网络连接,或在 ⚙️ 设置中更换模型。</small>';
}
A.msgs.pop();
}finally{
A.streaming=false; updSendBtn(); scrollB();
@ -1170,7 +1380,9 @@ function closeP(){
// ═══════════════════════════════════════════════════════
// COPY URL (share link)
// ═══════════════════════════════════════════════════════
const CHAT_URL = 'https://' + REPO.split('/')[0] + '.github.io/' + REPO.split('/')[1] + '/';
// Auto-detect the real URL so the share link is always correct
// regardless of whether Pages serves from docs/ or repo root.
const CHAT_URL = window.location.origin + window.location.pathname;
function showCopyFeedback(success){
const btn=document.querySelector('.copy-url-btn');

366
scripts/notion-bridge.js Normal file
View File

@ -0,0 +1,366 @@
// scripts/notion-bridge.js
// 铸渊 → Notion 数据桥(共用模块)
//
// 管道 A (syslog): node scripts/notion-bridge.js syslog
// 管道 E (changes): node scripts/notion-bridge.js changes
//
// 必需环境变量:
// NOTION_TOKEN GitHub Secret: NOTION_TOKEN
//
// 可选环境变量(有内置默认值):
// SYSLOG_DB_ID 「GitHub SYSLOG 收件箱」database_id默认已内置
// CHANGES_DB_ID 「GitHub 变更日志」database_id默认已内置
//
// 管道 E 额外环境变量(由 workflow 注入,见下方说明)
'use strict';
const https = require('https');
const fs = require('fs');
const path = require('path');
// ══════════════════════════════════════════════════════════
// 常量
// ══════════════════════════════════════════════════════════
const NOTION_VERSION = '2022-06-28';
const NOTION_API_HOSTNAME = 'api.notion.com';
const NOTION_RICH_TEXT_MAX = 2000; // Notion rich_text 单个 text object 内容上限
const NOTION_TITLE_MAX = 120; // Notion 标题属性建议截断长度
const UNKNOWN_COMMITTER = '未知'; // 提交者信息缺失时的默认值
// Notion 数据库 ID霜砚已在 Notion 侧建好ID 固定)
const DEFAULT_SYSLOG_DB_ID = '330ab17507d542c9bbb96d0749b41197';
const DEFAULT_CHANGES_DB_ID = 'e740b77aa6bd4ac0a2e8a75f678fba98';
// ══════════════════════════════════════════════════════════
// Notion API 基础调用
// ══════════════════════════════════════════════════════════
/**
* Notion API 发起 HTTPS POST 请求
* @param {string} endpoint - e.g. '/v1/pages'
* @param {object} body - JSON body
* @param {string} token - Bearer token
* @returns {Promise<object>} - Notion API 返回值
*/
function notionPost(endpoint, body, token) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
const opts = {
hostname: NOTION_API_HOSTNAME,
port: 443,
path: endpoint,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Notion-Version': NOTION_VERSION,
'Content-Length': Buffer.byteLength(payload),
},
};
const req = https.request(opts, res => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(parsed);
} else {
reject(new Error(`Notion API ${res.statusCode}: ${parsed.message || data}`));
}
} catch (e) {
reject(new Error(`Notion API parse error: ${data}`));
}
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
// ══════════════════════════════════════════════════════════
// Notion 属性构建辅助函数
// ══════════════════════════════════════════════════════════
/**
* 构建 Notion rich_text 属性值
* 自动将超过 NOTION_RICH_TEXT_MAX 的内容切分为多个 text object
* @param {string} content
* @returns {Array}
*/
function richText(content) {
const str = String(content || '');
const chunks = [];
for (let i = 0; i < str.length; i += NOTION_RICH_TEXT_MAX) {
chunks.push({ type: 'text', text: { content: str.slice(i, i + NOTION_RICH_TEXT_MAX) } });
}
return chunks.length ? chunks : [{ type: 'text', text: { content: '' } }];
}
/**
* 构建 Notion title 属性值
* @param {string} content
*/
function titleProp(content) {
return [{ type: 'text', text: { content: String(content || '').slice(0, NOTION_TITLE_MAX) } }];
}
/**
* 构建 Notion paragraph block用于页面正文
* @param {string} content
*/
function paragraph(content) {
return {
object: 'block',
type: 'paragraph',
paragraph: { rich_text: richText(String(content || '').slice(0, NOTION_RICH_TEXT_MAX)) },
};
}
/**
* 构建 Notion heading_3 block
*/
function heading3(content) {
return {
object: 'block',
type: 'heading_3',
heading_3: { rich_text: [{ type: 'text', text: { content: String(content || '') } }] },
};
}
// ══════════════════════════════════════════════════════════
// 管道 A · SYSLOG 收件箱同步到 Notion
// ══════════════════════════════════════════════════════════
//
// Notion「📥 GitHub SYSLOG 收件箱」数据库属性(霜砚已建):
// 标题 title SYSLOG 文件名
// DEV编号 select 开发者编号,如 DEV-001
// 文件内容 rich_text SYSLOG 文件完整文本
// 接收时间 date 推送时间
// 处理状态 status 固定填「待处理」
// 来源路径 rich_text GitHub 中的文件路径
// commit_sha rich_text 对应的 Git commit SHA
// 推送方 rich_text 固定填「铸渊」
/**
* 将单条 syslog entry 写入 Notion 数据库
* @param {string} dbId - GitHub SYSLOG 收件箱database_id
* @param {string} fileContent - 文件原始内容字符串
* @param {string} filePath - 相对路径用于溯源
* @param {object} entry - 解析后的 JSON或空对象
* @param {string} commitSha - Git commit SHA
* @param {string} token - Notion token
*/
async function createSyslogRecord(dbId, fileContent, filePath, entry, commitSha, token) {
const filename = path.basename(filePath);
const title = entry.title || filename;
const devId = entry.from || entry.dev_id || '';
const ts = entry.timestamp || new Date().toISOString();
const properties = {
'标题': { title: titleProp(title) },
'文件内容': { rich_text: richText(fileContent) },
'来源路径': { rich_text: richText(filePath) },
'接收时间': { date: { start: ts } },
'处理状态': { status: { name: '待处理' } },
'commit_sha': { rich_text: richText(commitSha || '') },
'推送方': { rich_text: richText('铸渊') },
};
// DEV编号 select 只在有值时设置(空值会导致 Notion API 报错)
if (devId) {
properties['DEV编号'] = { select: { name: devId } };
}
const body = {
parent: { database_id: dbId },
properties,
};
return notionPost('/v1/pages', body, token);
}
/**
* 递归扫描目录返回所有匹配的文件路径
*/
function scanDir(dir, extensions) {
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === '.gitkeep') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...scanDir(fullPath, extensions));
} else if (extensions.some(ext => entry.name.endsWith(ext))) {
results.push(fullPath);
}
}
return results;
}
async function runPipelineA() {
const token = process.env.NOTION_TOKEN;
const dbId = process.env.SYSLOG_DB_ID || DEFAULT_SYSLOG_DB_ID;
const commitSha = process.env.COMMIT_SHA || '';
if (!token) {
console.log('⚠️ 管道A: 缺少 NOTION_TOKEN跳过 Notion 同步');
process.exit(0);
}
const INBOX_DIR = 'syslog-inbox';
const files = scanDir(INBOX_DIR, ['.json', '.md', '.txt']);
if (files.length === 0) {
console.log('📭 syslog-inbox/ 无待处理条目,跳过 Notion 同步');
process.exit(0);
}
console.log(`📥 管道A: 发现 ${files.length} 条 syslog开始同步到 Notion…`);
let ok = 0, failed = 0;
for (const fullPath of files) {
const relPath = fullPath.replace(/\\/g, '/'); // normalize on Windows
let raw, entry;
try {
raw = fs.readFileSync(fullPath, 'utf8');
entry = fullPath.endsWith('.json') ? JSON.parse(raw) : {};
} catch (e) {
console.error(`❌ 读取 ${relPath} 失败: ${e.message}`);
failed++;
continue;
}
try {
const page = await createSyslogRecord(dbId, raw, relPath, entry, commitSha, token);
console.log(`${relPath} → Notion page: ${page.id}`);
ok++;
} catch (e) {
console.error(`${relPath} → Notion 失败: ${e.message}`);
failed++;
}
}
console.log(`\n✅ 管道A Notion 同步完成 · 成功 ${ok} 条 · 失败 ${failed}`);
if (failed > 0) process.exit(1);
}
// ══════════════════════════════════════════════════════════
// 管道 E · GitHub 变更日志同步到 Notion
// ══════════════════════════════════════════════════════════
//
// Notion「📋 GitHub 变更日志」数据库属性(霜砚已建):
// 标题 title commit message 或 PR标题
// 提交者 select DEV编号或"铸渊""妈妈"
// 变更类型 select Commit / PR opened / PR merged / PR closed
// 变更文件 rich_text 变更的文件路径列表
// 提交时间 date Git提交时间
// commit_sha rich_text Git commit SHA
// PR编号 rich_text PR号PR事件
// 分支 rich_text 分支名
// 霜砚已读 checkbox 固定填 false
//
// 环境变量(由 workflow 注入):
// EVENT_TYPE commit | pr
// COMMIT_SHA 提交 SHA
// COMMIT_MSG commit messagepush 事件)
// COMMITTER 提交者用户名
// COMMIT_TIMESTAMP 提交时间 ISO
// CHANGED_FILES 换行符分隔的变更文件路径列表
// BRANCH 分支名
// PR_NUMBER PR 编号pr 事件)
// PR_TITLE PR 标题pr 事件)
// PR_ACTION opened | closedpr 事件)
// PR_MERGED true | falsepr 事件)
async function runPipelineE() {
const token = process.env.NOTION_TOKEN;
const dbId = process.env.CHANGES_DB_ID || DEFAULT_CHANGES_DB_ID;
if (!token) {
console.log('⚠️ 管道E: 缺少 NOTION_TOKEN跳过 Notion 同步');
process.exit(0);
}
const eventType = process.env.EVENT_TYPE || 'commit';
const now = process.env.COMMIT_TIMESTAMP || new Date().toISOString();
const commitSha = process.env.COMMIT_SHA || '';
const branch = process.env.BRANCH || '';
const changedFiles = (process.env.CHANGED_FILES || '').trim();
const committer = process.env.COMMITTER || UNKNOWN_COMMITTER;
let title, changeType, prNumber;
if (eventType === 'pr') {
const action = process.env.PR_ACTION || 'opened';
const merged = process.env.PR_MERGED === 'true';
prNumber = process.env.PR_NUMBER || '';
const prTitle = process.env.PR_TITLE || '(无标题)';
if (merged) changeType = 'PR merged';
else if (action === 'closed') changeType = 'PR closed';
else changeType = 'PR opened';
title = `PR #${prNumber}: ${prTitle}`.slice(0, NOTION_TITLE_MAX);
} else {
const msg = process.env.COMMIT_MSG || '(无 commit message)';
title = msg.split('\n')[0].slice(0, NOTION_TITLE_MAX);
changeType = 'Commit';
prNumber = '';
}
console.log(`📡 管道E: 同步变更记录到 Notion: ${title}`);
const properties = {
'标题': { title: titleProp(title) },
'变更类型': { select: { name: changeType } },
'变更文件': { rich_text: richText(changedFiles) },
'提交时间': { date: { start: now } },
'commit_sha': { rich_text: richText(commitSha) },
'PR编号': { rich_text: richText(prNumber || '') },
'分支': { rich_text: richText(branch) },
'霜砚已读': { checkbox: false },
};
// 提交者 select 只在有值时设置
if (committer && committer !== UNKNOWN_COMMITTER) {
properties['提交者'] = { select: { name: committer } };
}
const body = {
parent: { database_id: dbId },
properties,
};
try {
const page = await notionPost('/v1/pages', body, token);
console.log(`✅ 管道E 变更记录已写入 Notion: ${page.id}`);
} catch (e) {
console.error(`❌ 管道E 写入 Notion 失败: ${e.message}`);
process.exit(1);
}
}
// ══════════════════════════════════════════════════════════
// 入口
// ══════════════════════════════════════════════════════════
const mode = process.argv[2];
if (mode === 'syslog') {
runPipelineA().catch(e => { console.error(e); process.exit(1); });
} else if (mode === 'changes') {
runPipelineE().catch(e => { console.error(e); process.exit(1); });
} else {
console.error('用法: node scripts/notion-bridge.js [syslog|changes]');
process.exit(1);
}

138
scripts/process-syslog.js Normal file
View File

@ -0,0 +1,138 @@
// scripts/process-syslog.js
// 铸渊 SYSLOG Pipeline
// 读取 syslog-inbox/ 下的日志条目,处理后归档到 syslog-processed/
// 触发syslog-inbox/ 目录有新文件 push 到 main 分支
'use strict';
const fs = require('fs');
const path = require('path');
const INBOX_DIR = 'syslog-inbox';
const ARCHIVE_DIR = 'syslog-processed';
const BRAIN_DIR = '.github/brain';
const MEMORY_PATH = path.join(BRAIN_DIR, 'memory.json');
const DEV_STATUS_PATH = '.github/persona-brain/dev-status.json';
// ─── 加载大脑 ────────────────────────────────────────────
let memory = {};
try {
memory = JSON.parse(fs.readFileSync(MEMORY_PATH, 'utf8'));
} catch (_) {
memory = { events: [], stats: { broadcasts_processed: 0 } };
}
if (!memory.events) memory.events = [];
if (!memory.stats) memory.stats = {};
if (!memory.stats.broadcasts_processed) memory.stats.broadcasts_processed = 0;
if (!memory.stats.syslog_processed) memory.stats.syslog_processed = 0;
// ─── 扫描 inbox ──────────────────────────────────────────
if (!fs.existsSync(INBOX_DIR)) {
console.log('📭 syslog-inbox/ 目录不存在,跳过');
process.exit(0);
}
const files = fs.readdirSync(INBOX_DIR)
.filter(f => f.endsWith('.json') && f !== '.gitkeep');
if (files.length === 0) {
console.log('📭 syslog-inbox/ 无待处理条目');
process.exit(0);
}
console.log(`📥 发现 ${files.length} 条 syslog 条目,开始处理...\n`);
// ─── 按月归档路径 ─────────────────────────────────────────
function archiveDir(timestamp) {
const month = (timestamp || new Date().toISOString()).slice(0, 7); // "2026-03"
const dir = path.join(ARCHIVE_DIR, month);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
// ─── 处理每条 syslog ──────────────────────────────────────
files.forEach(file => {
const fullPath = path.join(INBOX_DIR, file);
let entry;
try {
entry = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
} catch (e) {
console.error(`❌ [INVALID JSON] ${file}: ${e.message},跳过`);
return;
}
console.log(`📋 处理: ${file}`);
console.log(` 类型: ${entry.type || '未知'} · 来自: ${entry.from || '未知'} · 标题: ${entry.title || ''}`);
const event = {
timestamp: entry.timestamp || new Date().toISOString(),
type: 'syslog_' + (entry.type || 'unknown'),
syslog_id: entry.syslog_id || file,
title: entry.title || '(无标题)',
from: entry.from || '未知',
file,
};
// ── 根据类型分发处理 ──
switch (entry.type) {
case 'broadcast': {
// 广播类:追加到 brain/memory.json events与现有 broadcast pipeline 互补
memory.stats.broadcasts_processed += 1;
event.content_preview = (entry.content || '').slice(0, 80);
console.log(' ✅ 广播已记录到大脑记忆');
break;
}
case 'auth': {
// 授权类:记录授权事件,写入 memory
event.target_dev_id = entry.target_dev_id;
event.target_name = entry.target_name;
event.permission_level = entry.permission_level;
event.authorized_by = entry.authorized_by || '冰朔';
event.valid_until = entry.valid_until;
console.log(` ✅ 授权记录:${entry.target_name}${entry.target_dev_id})· 权限:${entry.permission_level}`);
break;
}
case 'inspect': {
// 巡检类:记录巡检结果
event.inspect_result = entry.result || 'unknown';
event.issues_found = entry.issues_found || 0;
console.log(` ✅ 巡检记录:结果 ${event.inspect_result} · 发现问题 ${event.issues_found}`);
break;
}
case 'alert': {
// 告警类
event.severity = entry.priority || 'normal';
console.log(` ⚠️ 告警记录:${entry.title} · 优先级 ${event.severity}`);
break;
}
default:
console.log(` 未知类型 "${entry.type}",已记录原始内容`);
event.raw = entry;
}
// 写入大脑记忆
memory.events.push(event);
memory.stats.syslog_processed = (memory.stats.syslog_processed || 0) + 1;
memory.last_updated = new Date().toISOString();
// 归档文件
const destDir = archiveDir(entry.timestamp);
const destPath = path.join(destDir, file);
fs.renameSync(fullPath, destPath);
console.log(` 📦 已归档到 ${destPath}\n`);
});
// ─── 保存大脑 ─────────────────────────────────────────────
// 只保留最近 100 条事件,防止 memory.json 无限膨胀
if (memory.events.length > 100) {
memory.events = memory.events.slice(-100);
}
fs.writeFileSync(MEMORY_PATH, JSON.stringify(memory, null, 2));
console.log(`✅ SYSLOG Pipeline 完成 · 累计处理 ${memory.stats.syslog_processed}`);

View File

@ -32,6 +32,25 @@ if (missing.length > 0) {
console.log('✅ 大脑文件完整性:全部就绪');
}
// === ① bis · 目录结构巡检 ===
const requiredDirs = ['syslog-inbox', 'syslog-processed', 'broadcasts-outbox', '.github/brain'];
const missingDirs = requiredDirs.filter(d => !fs.existsSync(d));
if (missingDirs.length > 0) {
console.error('⚠️ 缺失目录:' + missingDirs.join(', '));
} else {
console.log('✅ 目录结构巡检syslog-inbox / syslog-processed / broadcasts-outbox 全部就绪');
}
// 统计 syslog-inbox 待处理条目
const inboxFiles = fs.existsSync('syslog-inbox')
? fs.readdirSync('syslog-inbox').filter(f => f.endsWith('.json')).length
: 0;
if (inboxFiles > 0) {
console.log(`📥 syslog-inbox 待处理条目:${inboxFiles} 个(建议触发 syslog-pipeline workflow`);
} else {
console.log('📭 syslog-inbox 无待处理条目');
}
// === ② 知识库去重与整理 ===
const seen = new Set();
const uniqueFaq = kb.faq.filter(item => {

0
syslog-inbox/.gitkeep Normal file
View File

58
syslog-inbox/README.md Normal file
View File

@ -0,0 +1,58 @@
# syslog-inbox · 系统日志收件箱
此目录由 **Notion 侧霜砚人格体** 写入GitHub 侧铸渊 Pipeline 读取处理。
## 目录用途
| 角色 | 操作 |
|------|------|
| Notion 侧(霜砚) | 将广播/授权/巡检日志写入此目录 |
| GitHub 侧(铸渊) | Pipeline 检测到新文件,处理后移入 `syslog-processed/` |
## 文件命名规范
```
{类型}-{日期}-{编号}.json
```
示例:
- `broadcast-2026-03-06-001.json` — 霜砚广播
- `auth-2026-03-06-DEV002.json` — 用户授权回执
- `inspect-2026-03-06-001.json` — 巡检记录
## 标准 JSON 格式
```json
{
"syslog_id": "BC-2026-03-06-001",
"type": "broadcast | auth | inspect | alert",
"from": "霜砚",
"to": "铸渊",
"timestamp": "2026-03-06T08:00:00Z",
"title": "广播标题",
"content": "内容正文",
"target_dev_id": "DEV-002",
"priority": "normal | high | urgent"
}
```
## 授权广播格式auth 类型)
冰朔通过 Notion 侧霜砚下发授权时,使用此格式:
```json
{
"syslog_id": "AUTH-2026-03-06-DEV002",
"type": "auth",
"from": "霜砚",
"authorized_by": "冰朔",
"timestamp": "2026-03-06T08:00:00Z",
"title": "用户授权",
"target_dev_id": "DEV-002",
"target_name": "肥猫",
"permission_level": "supreme",
"valid_until": "2026-12-31T23:59:59Z"
}
```
> ⚠️ 写入后请勿手动修改或删除Pipeline 处理完成后会自动归档到 `syslog-processed/`

View File

View File

@ -0,0 +1,9 @@
# syslog-processed · 已处理日志归档
此目录由 **铸渊 Pipeline** 自动管理,存放已处理完成的 syslog 条目。
- 文件从 `syslog-inbox/` 处理完毕后自动移入此处
- 按月分子目录归档:`syslog-processed/2026-03/`
- **请勿手动修改此目录内容**
> 此目录仅作归档用途,任何查询请读取 `.github/brain/memory.json`