Compare commits

...

11 Commits

Author SHA1 Message Date
jiantw83 c66a9aa025 fix(llm): 強化 OpenCode JSON 回應解析 2026-06-17 07:08:11 +00:00
jiantw83 3bfdefa4ba chore(ai-review): 清空已處理 findings 2026-06-17 06:51:08 +00:00
jiantw83 1492f1b9b4 docs(README): 補上 OpenCode server 設定說明 2026-06-17 06:51:03 +00:00
jiantw83 a9ac7857be test(comments): 改善嚴重問題留言斷言可讀性 2026-06-17 06:50:56 +00:00
jiantw83 3f78543159 test(opencode): 補上 OpenCode server provider 測試 2026-06-17 06:50:51 +00:00
jiantw83 98eaeb6050 feat(opencode): 新增 OpenCode server provider 串接 2026-06-17 06:50:44 +00:00
jiantw83 ffc9038923 Merge pull request '優化 Step2:改用 skill RPG 攻防腳色系統(新增 Mage 邏輯角色、Step3/4 套 Paladin 裁決人設)' (#14) from feat/optimize-step2 into develop
Reviewed-on: #14
2026-06-16 09:04:08 +00:00
AI Review Bot 862f4e46ef chore: update ai-review findings [ai-review-bot][success]
AI / 計算版本號 (pull_request) Successful in 4s
AI / Code Review (pull_request) Successful in 10s
2026-06-16 09:03:46 +00:00
Jeffery 97888f8b22 chore(ai-review): 清空 findings
AI / 計算版本號 (pull_request) Successful in 3s
AI / Code Review (pull_request) Successful in 2m11s
2026-06-16 17:00:36 +08:00
Jeffery fa95a463f8 test(roles): 補 focus/personality 缺漏時的輸出防護測試 2026-06-16 17:00:29 +08:00
Jeffery 60001499da fix(腳色載入器): 壞角色檔改記錄警告並略過、快取解析結果並補 focus/personality 缺漏防護 2026-06-16 17:00:24 +08:00
14 changed files with 437 additions and 46 deletions
+1 -30
View File
@@ -1,30 +1 @@
[ []
{
"level": "critical",
"role": "Mage",
"location": "app/roles.js:30",
"suggestion": "在 `parseRoleFile` 函式中,`yaml.load(match[1])` 若遇到格式錯誤的 YAML 內容,會拋出未捕捉的例外,導致應用程式崩潰。應加入 `try-catch` 區塊來處理此潛在錯誤,確保即使角色定義檔有誤,系統也能穩定運行,例如回傳一個錯誤物件或記錄錯誤並跳過該檔案。",
"is_new": true
},
{
"level": "critical",
"role": "Rogue",
"location": "app/roles.js:30",
"suggestion": "「loadRole」函式每次被呼叫時,都會重新讀取並解析所有角色檔案。這會造成不必要的同步檔案 I/O 與 CPU 浪費,尤其當此函式被頻繁呼叫時,會嚴重阻塞事件迴圈。建議將「readRoleFiles()」的結果快取起來,讓「loadRole」直接從記憶體中查詢,避免重複讀取磁碟。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "app/roles.js:60",
"suggestion": "在 `buildAnalysisPrompt` 函式中,`role.focus` 屬性被直接用於字串模板。若角色定義檔中缺少 `focus` 欄位,此處將會顯示為 `負責「undefined」面向`,導致生成的提示語義不完整。建議在引用前檢查 `role.focus` 是否存在,或提供一個預設值,例如:`負責「${role.focus || '未定義'}」面向`。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "app/roles.js:77",
"suggestion": "在 `getRoleIntro` 函式中,`r.focus` 和 `r.personality` 屬性被直接用於生成 Markdown 表格。若角色定義檔中缺少這些欄位,表格中將會顯示 `undefined`,影響可讀性與呈現品質。建議在引用前檢查這些屬性是否存在,並提供一個空字串或預設值,例如:`| **${badge}${r.name}** | ${r.focus || ''} | ${r.personality || ''} |`。",
"is_new": true
}
]
+56 -1
View File
@@ -74,6 +74,14 @@ jobs:
issues: write issues: write
``` ```
OpenAI GPT-5.5 會透過 Responses API 呼叫;設定方式仍使用 `OPENAI_*`
```yaml
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: https://api.openai.com/v1
OPENAI_MODEL: gpt-5.5
```
### 2. OpenRouter ### 2. OpenRouter
```yaml ```yaml
name: AI name: AI
@@ -192,7 +200,54 @@ jobs:
issues: write issues: write
``` ```
### 6. Ollama ### 6. OpenCode Server
```yaml
name: AI
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
on:
pull_request:
branches-ignore:
- master
types: [opened, synchronize]
jobs:
code-review:
name: Code Review
runs-on: ubuntu
steps:
- name: AI Code Review
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
with:
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
OPENCODE_BASE_URL: http://192.168.3.124:4096
OPENCODE_PROVIDER: google
OPENCODE_MODEL: gemini-2.5-flash
# 若 OpenCode server 有設定 OPENCODE_SERVER_PASSWORD,才需要提供:
# OPENCODE_SERVER_USERNAME: opencode
# OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
permissions:
contents: write
pull-requests: write
issues: write
```
OpenCode Server 串接方式會呼叫 server root 的 `/session``/session/{sessionID}/message`,並把模型指定為 `providerID=google``modelID=gemini-2.5-flash`。可用的內部 OpenCode server
```yaml
OPENCODE_BASE_URL: https://opencode.jsc.idv.me
```
或:
```yaml
OPENCODE_BASE_URL: http://192.168.3.124:4096
```
OpenCode server 本身必須已設定好 `google` provider 與 `gemini-2.5-flash` model;此 action 不會把 Google API key 傳給 OpenCode server。
### 7. Ollama
```yaml ```yaml
name: AI name: AI
+22
View File
@@ -79,6 +79,23 @@ inputs:
description: 'Amazon Q Base URL' description: 'Amazon Q Base URL'
required: false required: false
# OpenCode Server
OPENCODE_BASE_URL:
description: 'OpenCode server Base URL'
required: false
OPENCODE_MODEL:
description: 'OpenCode model id'
required: false
OPENCODE_PROVIDER:
description: 'OpenCode server provider id'
required: false
OPENCODE_SERVER_USERNAME:
description: 'OpenCode server Basic Auth username'
required: false
OPENCODE_SERVER_PASSWORD:
description: 'OpenCode server Basic Auth password'
required: false
runs: runs:
using: 'docker' using: 'docker'
image: 'Dockerfile' image: 'Dockerfile'
@@ -107,3 +124,8 @@ runs:
OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }} OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }}
AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }} AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }}
AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }} AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }}
OPENCODE_BASE_URL: ${{ inputs.OPENCODE_BASE_URL }}
OPENCODE_MODEL: ${{ inputs.OPENCODE_MODEL }}
OPENCODE_PROVIDER: ${{ inputs.OPENCODE_PROVIDER }}
OPENCODE_SERVER_USERNAME: ${{ inputs.OPENCODE_SERVER_USERNAME }}
OPENCODE_SERVER_PASSWORD: ${{ inputs.OPENCODE_SERVER_PASSWORD }}
+2 -1
View File
@@ -163,6 +163,7 @@ describe('postNewCriticalComments', () => {
}); });
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => { it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
const criticalCommentPattern = /嚴重問題/;
const inlineCalls = []; const inlineCalls = [];
const issueCalls = []; const issueCalls = [];
const findings = [ const findings = [
@@ -181,6 +182,6 @@ describe('postNewCriticalComments', () => {
assert.equal(inlineCalls[0].path, 'app/a.js'); assert.equal(inlineCalls[0].path, 'app/a.js');
assert.equal(inlineCalls[0].line, 10); assert.equal(inlineCalls[0].line, 10);
assert.equal(issueCalls.length, 2); assert.equal(issueCalls.length, 2);
assert.ok(issueCalls.every(b => /嚴重問題/.test(b))); assert.ok(issueCalls.every(b => criticalCommentPattern.test(b)));
}); });
}); });
+1
View File
@@ -24,6 +24,7 @@ export function getLLMConfig() {
['gemini', splitKeys(process.env.GEMINI_API_KEY), process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'], ['gemini', splitKeys(process.env.GEMINI_API_KEY), process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'],
['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL], ['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL],
['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'], ['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'],
['opencode', ['opencode'], process.env.OPENCODE_BASE_URL, process.env.OPENCODE_MODEL || 'gemini-2.5-flash'],
]; ];
for (const [provider, apiKeys, baseURL, model] of checks) { for (const [provider, apiKeys, baseURL, model] of checks) {
if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model }; if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model };
+20
View File
@@ -8,6 +8,8 @@ const ENV_KEYS = [
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL', 'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
'OLLAMA_BASE_URL', 'OLLAMA_MODEL', 'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
]; ];
let saved = {}; let saved = {};
@@ -84,6 +86,24 @@ describe('getLLMConfig', () => {
assert.equal(cfg.model, 'my-amazon-model'); assert.equal(cfg.model, 'my-amazon-model');
}); });
it('detects opencode server with gemini defaults', () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
const cfg = getLLMConfig();
assert.equal(cfg.provider, 'opencode');
assert.deepEqual(cfg.apiKeys, ['opencode']);
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
assert.equal(cfg.model, 'gemini-2.5-flash');
});
it('detects opencode server with custom model', () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
const cfg = getLLMConfig();
assert.equal(cfg.provider, 'opencode');
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
assert.equal(cfg.model, 'google/gemini-2.5-pro');
});
it('openai takes priority over gemini when both set', () => { it('openai takes priority over gemini when both set', () => {
process.env.OPENAI_API_KEY = 'sk-test'; process.env.OPENAI_API_KEY = 'sk-test';
process.env.GEMINI_API_KEY = 'gemini-key'; process.env.GEMINI_API_KEY = 'gemini-key';
+4
View File
@@ -203,6 +203,10 @@ export async function mergeInstructionText(existingText, sourceText, relPath, ai
try { try {
const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks }); const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks });
if (aiMerged == null) {
warn(`[merge] ${relPath} AI result unavailable; using deterministic merge`);
return deterministic;
}
if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) { if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) {
return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged; return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged;
} }
+9
View File
@@ -211,6 +211,15 @@ describe('commitAndPush', () => {
assert.ok(result.includes('extra block')); assert.ok(result.includes('extra block'));
}); });
it('uses deterministic instruction merge when AI returns no usable result', async () => {
const aiMergeAssistant = async () => null;
const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant);
assert.ok(result.includes('repo block'));
assert.ok(result.includes('source block'));
});
it('exits when AI output drops a block', async () => { it('exits when AI output drops a block', async () => {
const originalExit = process.exit; const originalExit = process.exit;
let exitCode = null; let exitCode = null;
+144 -5
View File
@@ -2,6 +2,81 @@ import axios from 'axios';
import { getLLMConfig } from './config.js'; import { getLLMConfig } from './config.js';
import { line, error } from './log.js'; import { line, error } from './log.js';
function isOpenAIGpt55(provider, model) {
return provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || '');
}
function chatEndpoint(baseURL, provider, model) {
const base = baseURL.replace(/\/$/, '');
return isOpenAIGpt55(provider, model) ? `${base}/responses` : `${base}/chat/completions`;
}
function chatPayload(provider, model, systemPrompt, userContent) {
if (isOpenAIGpt55(provider, model)) {
return { model, instructions: systemPrompt, input: userContent, temperature: 0.2 };
}
return { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 };
}
function extractContent(provider, model, data) {
if (!isOpenAIGpt55(provider, model)) return data.choices[0].message.content;
if (typeof data.output_text === 'string') return data.output_text;
const parts = data.output?.flatMap(item => item.content || []) || [];
const text = parts
.map(part => {
if (typeof part.text === 'string') return part.text;
if (typeof part.content === 'string') return part.content;
return '';
})
.filter(Boolean)
.join('');
if (text) return text;
return data.choices?.[0]?.message?.content || '';
}
function opencodeModelConfig(model) {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID };
}
function applyOpenCodeAuth(headers) {
const password = process.env.OPENCODE_SERVER_PASSWORD;
if (!password) return;
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
function extractOpenCodeContent(data) {
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
return parts
.map(part => part.text || part.content || '')
.filter(Boolean)
.join('');
}
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
const base = baseURL.replace(/\/$/, '');
const { providerID, modelID } = opencodeModelConfig(model);
const session = await axios.post(
`${base}/session`,
{ title: 'AI Code Review', model: { providerID, id: modelID } },
{ headers }
);
const sessionID = session.data.id || session.data.data?.id;
if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id');
const resp = await axios.post(
`${base}/session/${sessionID}/message`,
{
model: { providerID, modelID },
system: systemPrompt,
parts: [{ type: 'text', text: userContent }],
},
{ headers }
);
return extractOpenCodeContent(resp.data);
}
export async function chat(systemPrompt, userContent) { export async function chat(systemPrompt, userContent) {
const { provider, apiKeys, baseURL, model } = getLLMConfig(); const { provider, apiKeys, baseURL, model } = getLLMConfig();
if (!provider) throw new Error('未設定任何 LLM API Key'); if (!provider) throw new Error('未設定任何 LLM API Key');
@@ -13,14 +88,18 @@ export async function chat(systemPrompt, userContent) {
const shuffled = [...apiKeys].sort(() => Math.random() - 0.5); const shuffled = [...apiKeys].sort(() => Math.random() - 0.5);
for (let i = 0; i < shuffled.length; i++) { for (let i = 0; i < shuffled.length; i++) {
if (provider !== 'ollama') headers['Authorization'] = `Bearer ${shuffled[i]}`; if (provider !== 'ollama' && provider !== 'opencode') headers['Authorization'] = `Bearer ${shuffled[i]}`;
try { try {
if (provider === 'opencode') {
applyOpenCodeAuth(headers);
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
}
const resp = await axios.post( const resp = await axios.post(
`${baseURL.replace(/\/$/, '')}/chat/completions`, chatEndpoint(baseURL, provider, model),
{ model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 }, chatPayload(provider, model, systemPrompt, userContent),
{ headers } { headers }
); );
return resp.data.choices[0].message.content; return extractContent(provider, model, resp.data);
} catch (e) { } catch (e) {
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
} }
@@ -32,9 +111,69 @@ export async function chat(systemPrompt, userContent) {
export async function chatJSON(systemPrompt, userContent) { export async function chatJSON(systemPrompt, userContent) {
const text = await chat(systemPrompt, userContent); const text = await chat(systemPrompt, userContent);
try { try {
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim()); return JSON.parse(extractJSONText(text));
} catch (e) { } catch (e) {
line(`[LLM] JSON 解析失敗: ${e.message}`); line(`[LLM] JSON 解析失敗: ${e.message}`);
return []; return [];
} }
} }
function stripOuterFence(text) {
return String(text)
.trim()
.replace(/^```[a-zA-Z0-9_-]*\n?/, '')
.replace(/```$/, '')
.trim();
}
function extractBalancedJSON(text, startIndex) {
const source = String(text);
const open = source[startIndex];
const close = open === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escaped = false;
for (let i = startIndex; i < source.length; i++) {
const ch = source[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) return source.slice(startIndex, i + 1);
}
}
return null;
}
function extractJSONText(text) {
const stripped = stripOuterFence(text);
try {
JSON.parse(stripped);
return stripped;
} catch {}
for (let i = 0; i < stripped.length; i++) {
if (stripped[i] !== '[' && stripped[i] !== '{') continue;
const candidate = extractBalancedJSON(stripped, i);
if (!candidate) continue;
try {
JSON.parse(candidate);
return candidate;
} catch {}
}
return stripped;
}
+78
View File
@@ -10,6 +10,8 @@ const ENV_KEYS = [
'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL', 'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL',
'OLLAMA_BASE_URL', 'OLLAMA_MODEL', 'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
]; ];
let saved = {}; let saved = {};
@@ -125,6 +127,68 @@ describe('chat - key rotation', async () => {
await chat('sys', 'user'); await chat('sys', 'user');
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01'); assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
}); });
it('uses OpenCode server session API for opencode', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
process.env.OPENCODE_PROVIDER = 'google';
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
const calls = [];
mock.method(axios, 'post', async (url, payload, opts) => {
calls.push({ url, payload, headers: opts.headers });
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
return { data: { parts: [{ type: 'text', text: 'opencode response' }] } };
});
const result = await chat('sys', 'user');
assert.equal(result, 'opencode response');
assert.equal(calls[0].url, 'http://opencode.local:4096/session');
assert.deepEqual(calls[0].payload.model, { providerID: 'google', id: 'gemini-2.5-flash' });
assert.equal(calls[1].url, 'http://opencode.local:4096/session/ses_test/message');
assert.deepEqual(calls[1].payload.model, { providerID: 'google', modelID: 'gemini-2.5-flash' });
assert.equal(calls[1].payload.system, 'sys');
assert.deepEqual(calls[1].payload.parts, [{ type: 'text', text: 'user' }]);
assert.equal(calls[1].headers['Authorization'], undefined);
});
it('uses Basic Auth for protected OpenCode server', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
process.env.OPENCODE_SERVER_USERNAME = 'opencode';
process.env.OPENCODE_SERVER_PASSWORD = 'secret';
const headers = [];
mock.method(axios, 'post', async (url, _payload, opts) => {
headers.push(opts.headers);
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
return { data: { parts: [{ type: 'text', text: 'ok' }] } };
});
await chat('sys', 'user');
assert.equal(headers[0]['Authorization'], `Basic ${Buffer.from('opencode:secret').toString('base64')}`);
});
it('uses Responses API for openai GPT-5.5', async () => {
process.env.OPENAI_API_KEY = 'sk-test';
process.env.OPENAI_MODEL = 'GPT-5.5';
let capturedUrl, capturedPayload;
mock.method(axios, 'post', async (url, payload) => {
capturedUrl = url;
capturedPayload = payload;
return { data: { output_text: 'gpt response' } };
});
const result = await chat('sys', 'user');
assert.equal(result, 'gpt response');
assert.equal(capturedUrl, 'https://api.openai.com/v1/responses');
assert.deepEqual(capturedPayload, { model: 'GPT-5.5', instructions: 'sys', input: 'user', temperature: 0.2 });
});
it('extracts opencode text from message parts', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
let calls = 0;
mock.method(axios, 'post', async () => {
calls += 1;
if (calls === 1) return { data: { id: 'ses_test' } };
return { data: { parts: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } };
});
const result = await chat('sys', 'user');
assert.equal(result, 'hello world');
});
}); });
describe('chatJSON', async () => { describe('chatJSON', async () => {
@@ -144,6 +208,20 @@ describe('chatJSON', async () => {
assert.deepEqual(result, [{ level: 'info' }]); assert.deepEqual(result, [{ level: 'info' }]);
}); });
it('extracts JSON array from surrounding prose', async () => {
process.env.OPENAI_API_KEY = 'sk-test';
mockAxiosPost([makeOkResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.')]);
const result = await chatJSON('sys', 'user');
assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]);
});
it('extracts JSON object from surrounding prose', async () => {
process.env.OPENAI_API_KEY = 'sk-test';
mockAxiosPost([makeOkResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}')]);
const result = await chatJSON('sys', 'user');
assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' });
});
it('returns [] when JSON is invalid', async () => { it('returns [] when JSON is invalid', async () => {
process.env.OPENAI_API_KEY = 'sk-test'; process.env.OPENAI_API_KEY = 'sk-test';
mockAxiosPost([makeOkResponse('not json')]); mockAxiosPost([makeOkResponse('not json')]);
+32 -3
View File
@@ -15,6 +15,17 @@ import { step, line, ok, error } from './log.js';
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
const usesResponsesApi = (provider, model) => provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || '');
const opencodeModelConfig = (model) => {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID };
};
const applyOpenCodeAuth = (headers) => {
const password = process.env.OPENCODE_SERVER_PASSWORD;
if (!password) return;
const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
};
function giteaErr(e) { function giteaErr(e) {
const status = e.response?.status; const status = e.response?.status;
@@ -63,6 +74,7 @@ export async function verifyLLM() {
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` }; if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
const base = baseURL.replace(/\/$/, ''); const base = baseURL.replace(/\/$/, '');
const headers = { 'Content-Type': 'application/json' };
if (provider === 'ollama') { if (provider === 'ollama') {
try { try {
@@ -73,14 +85,31 @@ export async function verifyLLM() {
} }
} }
const headers = { 'Content-Type': 'application/json' }; if (provider === 'opencode') {
const { providerID, modelID } = opencodeModelConfig(model);
applyOpenCodeAuth(headers);
try {
await axios.get(`${base}/global/health`, { headers, timeout: 30000 });
const providers = await axios.get(`${base}/config/providers`, { headers, timeout: 30000 });
const configuredProvider = providers.data.providers?.find(p => p.id === providerID);
if (!configuredProvider) return { ok: false, provider, error: `OpenCode server 未設定 provider=${providerID}` };
if (!configuredProvider.models?.[modelID]) return { ok: false, provider, error: `OpenCode server provider=${providerID} 未列出 model=${modelID}` };
return { ok: true, provider };
} catch (e) {
return { ok: false, provider, error: `OpenCode server 驗證失敗: ${e.message}` };
}
}
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
const payload = { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 }; const endpoint = usesResponsesApi(provider, model) ? `${base}/responses` : `${base}/chat/completions`;
const payload = usesResponsesApi(provider, model)
? { model, input: 'ping', max_output_tokens: 1, temperature: 0 }
: { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
for (let i = 0; i < apiKeys.length; i++) { for (let i = 0; i < apiKeys.length; i++) {
headers['Authorization'] = `Bearer ${apiKeys[i]}`; headers['Authorization'] = `Bearer ${apiKeys[i]}`;
try { try {
await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000 }); await axios.post(endpoint, payload, { headers, timeout: 30000 });
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length }; return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
} catch (e) { } catch (e) {
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`); line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
+37
View File
@@ -9,6 +9,8 @@ const LLM_ENV_KEYS = [
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL', 'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
'OLLAMA_BASE_URL', 'OLLAMA_MODEL', 'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
]; ];
function clearLLMEnv() { function clearLLMEnv() {
@@ -163,6 +165,41 @@ describe('verifyLLM', () => {
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01'); assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
}); });
it('checks opencode server provider and model', async () => {
clearLLMEnv();
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
process.env.OPENCODE_PROVIDER = 'google';
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
const urls = [];
mock.method(axios, 'get', async (url) => {
urls.push(url);
if (url.endsWith('/global/health')) return { data: { healthy: true, version: '1.17.7' } };
return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } };
});
const result = await verifyLLM();
assert.equal(result.ok, true);
assert.equal(result.provider, 'opencode');
assert.deepEqual(urls, ['http://opencode.local:4096/global/health', 'http://opencode.local:4096/config/providers']);
});
it('checks openai GPT-5.5 with Responses API', async () => {
clearLLMEnv();
process.env.OPENAI_API_KEY = 'sk-test';
process.env.OPENAI_MODEL = 'GPT-5.5';
let capturedUrl, capturedPayload;
mock.method(axios, 'post', async (url, payload) => {
capturedUrl = url;
capturedPayload = payload;
return { data: { output_text: 'o' } };
});
const result = await verifyLLM();
assert.equal(result.ok, true);
assert.equal(result.provider, 'openai');
assert.equal(capturedUrl, 'https://api.openai.com/v1/responses');
assert.equal(capturedPayload.model, 'GPT-5.5');
assert.equal(capturedPayload.max_output_tokens, 1);
});
it('checks base URL connectivity for ollama (no key)', async () => { it('checks base URL connectivity for ollama (no key)', async () => {
clearLLMEnv(); clearLLMEnv();
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1'; process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
+20 -6
View File
@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { warn } from './log.js';
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles'); const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
@@ -17,11 +18,24 @@ export function parseRoleFile(content) {
return { ...meta, body: match[2].trim() }; return { ...meta, body: match[2].trim() };
} }
let cachedRoles = null;
/**
* 讀取並解析所有角色 .md,結果快取於模組層級(單次程序生命週期內檔案不變)。
* 單一檔案解析失敗(壞 YAML、缺 frontmatter 等)時記錄警告並略過,不讓整個流程崩潰。
*/
function readRoleFiles() { function readRoleFiles() {
return fs.readdirSync(ROLES_DIR) if (cachedRoles) return cachedRoles;
.filter(f => f.endsWith('.md')) const roles = [];
.sort() for (const f of fs.readdirSync(ROLES_DIR).filter(f => f.endsWith('.md')).sort()) {
.map(f => parseRoleFile(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8'))); try {
roles.push(parseRoleFile(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8')));
} catch (e) {
warn(`角色檔解析失敗,已略過: ${f}${e.message}`);
}
}
cachedRoles = roles;
return cachedRoles;
} }
/** /**
@@ -44,7 +58,7 @@ export function loadRole(name) {
*/ */
export function buildAnalysisPrompt(role) { export function buildAnalysisPrompt(role) {
return [ return [
`你是 ${role.badge ? role.badge + ' ' : ''}${role.name},負責「${role.focus}」面向的程式碼審查(攻擊方)。`, `你是 ${role.badge ? role.badge + ' ' : ''}${role.name},負責「${role.focus || '綜合'}」面向的程式碼審查(攻擊方)。`,
role.personality ? `個性:${role.personality}` : '', role.personality ? `個性:${role.personality}` : '',
'', '',
role.body, role.body,
@@ -77,7 +91,7 @@ export function getRoleIntro(roles) {
]; ];
for (const r of roles) { for (const r of roles) {
const badge = r.badge ? `${r.badge} ` : ''; const badge = r.badge ? `${r.badge} ` : '';
lines.push(`| **${badge}${r.name}** | ${r.focus} | ${r.personality} |`); lines.push(`| **${badge}${r.name}** | ${r.focus || ''} | ${r.personality || ''} |`);
} }
return lines.join('\n'); return lines.join('\n');
} }
+11
View File
@@ -72,6 +72,11 @@ describe('buildAnalysisPrompt', () => {
assert.match(prompt, /審查重點:邊界與空值/); assert.match(prompt, /審查重點:邊界與空值/);
assert.match(prompt, /只回傳 JSON 陣列/); assert.match(prompt, /只回傳 JSON 陣列/);
}); });
it('falls back to a default when focus is missing instead of showing undefined', () => {
const prompt = buildAnalysisPrompt({ name: 'NoFocus', body: 'x' });
assert.doesNotMatch(prompt, /undefined/);
});
}); });
describe('getRoleIntro', () => { describe('getRoleIntro', () => {
@@ -80,4 +85,10 @@ describe('getRoleIntro', () => {
assert.match(intro, /🔮 Tester/); assert.match(intro, /🔮 Tester/);
assert.match(intro, /logic/); assert.match(intro, /logic/);
}); });
it('renders empty cells instead of undefined when focus/personality are missing', () => {
const intro = getRoleIntro([{ name: 'Bare' }]);
assert.match(intro, /Bare/);
assert.doesNotMatch(intro, /undefined/);
});
}); });