botrun-horse 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/bh.mjs +193 -0
- package/bin/commands/dag-cmd.mjs +74 -0
- package/bin/commands/db-cmd.mjs +73 -0
- package/bin/commands/doc.mjs +185 -0
- package/bin/commands/gemini.mjs +120 -0
- package/bin/commands/help.mjs +109 -0
- package/bin/commands/legal.mjs +174 -0
- package/bin/commands/nchc.mjs +212 -0
- package/bin/commands/openrouter.mjs +154 -0
- package/bin/commands/prompt.mjs +175 -0
- package/bin/commands/schema.mjs +258 -0
- package/bin/commands/search.mjs +46 -0
- package/bin/commands/writing.mjs +33 -0
- package/lib/core/adapters/base.mjs +52 -0
- package/lib/core/adapters/claude.mjs +13 -0
- package/lib/core/adapters/gemini-api.mjs +174 -0
- package/lib/core/adapters/gemini-shared.mjs +164 -0
- package/lib/core/adapters/gemini-vertex.mjs +232 -0
- package/lib/core/adapters/local.mjs +13 -0
- package/lib/core/adapters/nchc.mjs +236 -0
- package/lib/core/adapters/openai-shared.mjs +34 -0
- package/lib/core/adapters/openrouter.mjs +304 -0
- package/lib/core/ai-cache.mjs +277 -0
- package/lib/core/ai-router.mjs +217 -0
- package/lib/core/cli-utils.mjs +170 -0
- package/lib/core/dag.mjs +114 -0
- package/lib/core/db.mjs +412 -0
- package/lib/core/env.mjs +64 -0
- package/lib/core/llm.mjs +58 -0
- package/lib/core/paths.mjs +115 -0
- package/lib/core/proxy.mjs +46 -0
- package/lib/core/watermelon.mjs +9 -0
- package/lib/doc/index.mjs +419 -0
- package/lib/doc/office2text.mjs +234 -0
- package/lib/doc/pdf2text.mjs +133 -0
- package/lib/doc/split.mjs +132 -0
- package/lib/flows/draft-writing.mjs +29 -0
- package/lib/flows/gemini-ask.mjs +185 -0
- package/lib/flows/hatch-portal.mjs +13 -0
- package/lib/flows/legal-ask.mjs +325 -0
- package/lib/flows/openai-agent.mjs +167 -0
- package/lib/flows/opencode-agent.mjs +240 -0
- package/lib/flows/openrouter-ask.mjs +111 -0
- package/lib/flows/review-doc.mjs +18 -0
- package/lib/ocr/index.mjs +6 -0
- package/lib/portal/hatch.mjs +6 -0
- package/lib/portal/index.mjs +6 -0
- package/lib/prompt/prompt-search.mjs +55 -0
- package/lib/prompt/prompt-store.mjs +94 -0
- package/lib/prompt/prompts/zero-framework/coding.md +15 -0
- package/lib/prompt/prompts/zero-framework/search.md +12 -0
- package/lib/prompt/prompts/zero-framework/slice.md +11 -0
- package/lib/search/crawler.mjs +6 -0
- package/lib/search/index.mjs +7 -0
- package/lib/tools/fs-tools.mjs +268 -0
- package/lib/tools/index.mjs +27 -0
- package/lib/writing/generate.mjs +86 -0
- package/lib/writing/generators/nstc-generators.mjs +279 -0
- package/lib/writing/generators/nstc-top5.mjs +554 -0
- package/lib/writing/index.mjs +5 -0
- package/lib/writing/layouts/nstc-layout.mjs +249 -0
- package/lib/writing/renderer.mjs +61 -0
- package/package.json +35 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// lib/core/adapters/gemini-api.mjs — Gemini 直接 API adapter(API Key 模式)
|
|
2
|
+
//
|
|
3
|
+
// 與 gemini-vertex.mjs 的差異:
|
|
4
|
+
// - 驗證方式:GEMINI_API_KEY(非 Vertex AI ADC)
|
|
5
|
+
// - 端點:generativelanguage.googleapis.com(非 aiplatform.googleapis.com)
|
|
6
|
+
// - 適用場景:無 GCP ADC 的容器環境、本機開發、CI
|
|
7
|
+
//
|
|
8
|
+
// 能力對等(與 Vertex AI 相同):
|
|
9
|
+
// - Google Search grounding(googleSearch: {})→ 引證 URL
|
|
10
|
+
// - URL Context 擷取(urlContext: {})
|
|
11
|
+
// - Thinking(thinkingLevel: MEDIUM)— Direct API 也支援相同列舉
|
|
12
|
+
// - Streaming
|
|
13
|
+
//
|
|
14
|
+
// 介面設計:完全相容 GeminiVertexAdapter(SOLID LSP)
|
|
15
|
+
// Proxy 相容:依賴 proxy.mjs 的全局 dispatcher,不自行建立 HTTP client
|
|
16
|
+
|
|
17
|
+
import { GoogleGenAI } from '@google/genai';
|
|
18
|
+
import { parseGeminiResponse, calcPerf, resolveRedirectUrls } from './gemini-shared.mjs';
|
|
19
|
+
import { BaseAdapter } from './base.mjs';
|
|
20
|
+
|
|
21
|
+
/** 預設設定常數(與 Vertex AI adapter 對等) */
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
model: 'gemini-3-flash-preview',
|
|
24
|
+
thinkingLevel: 'MEDIUM',
|
|
25
|
+
includeThoughts: true,
|
|
26
|
+
grounding: true,
|
|
27
|
+
urlContext: true,
|
|
28
|
+
maxOutputTokens: 8192,
|
|
29
|
+
temperature: 1.0,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gemini 直接 API Adapter(API Key 模式)
|
|
34
|
+
* 遵循 SOLID SRP:單一職責 — 封裝直接 Gemini API(非 Vertex AI)呼叫
|
|
35
|
+
*
|
|
36
|
+
* Direct API 的 thinkingConfig 與 Vertex AI 相同(thinkingLevel 列舉):
|
|
37
|
+
* NONE / MINIMAL / LOW / MEDIUM / HIGH
|
|
38
|
+
*/
|
|
39
|
+
export class GeminiApiAdapter extends BaseAdapter {
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} opts
|
|
42
|
+
* @param {string} [opts.apiKey] - Gemini API Key(預設讀 GEMINI_API_KEY)
|
|
43
|
+
* @param {string} [opts.thinkingLevel='MEDIUM'] - 思考等級(NONE|MINIMAL|LOW|MEDIUM|HIGH)
|
|
44
|
+
* @param {boolean} [opts.grounding=true] - 啟用 Google Search 接地(引證 URL)
|
|
45
|
+
* @param {boolean} [opts.urlContext=true] - 啟用 URL 內容擷取
|
|
46
|
+
* @param {boolean} [opts.includeThoughts=true] - 回傳思考摘要
|
|
47
|
+
*/
|
|
48
|
+
constructor(opts = {}) {
|
|
49
|
+
super();
|
|
50
|
+
const apiKey = opts.apiKey || process.env.GEMINI_API_KEY;
|
|
51
|
+
if (!apiKey) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
'GeminiApiAdapter: 需要 Gemini API Key。\n' +
|
|
54
|
+
'請設定環境變數 GEMINI_API_KEY 或傳入 apiKey 參數。'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.ai = new GoogleGenAI({ apiKey });
|
|
59
|
+
this.model = DEFAULTS.model;
|
|
60
|
+
this.thinkingLevel = (opts.thinkingLevel || DEFAULTS.thinkingLevel).toUpperCase();
|
|
61
|
+
this.includeThoughts = opts.includeThoughts ?? DEFAULTS.includeThoughts;
|
|
62
|
+
this.grounding = opts.grounding ?? DEFAULTS.grounding;
|
|
63
|
+
this.urlContext = opts.urlContext ?? DEFAULTS.urlContext;
|
|
64
|
+
this.maxOutputTokens = opts.maxOutputTokens || DEFAULTS.maxOutputTokens;
|
|
65
|
+
this.temperature = opts.temperature ?? DEFAULTS.temperature;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 建構 tools 陣列
|
|
70
|
+
* Direct API 工具名稱與 Vertex AI 相同(SDK 統一處理 camelCase)
|
|
71
|
+
*/
|
|
72
|
+
_buildTools() {
|
|
73
|
+
const tools = [];
|
|
74
|
+
if (this.grounding) tools.push({ googleSearch: {} });
|
|
75
|
+
if (this.urlContext) tools.push({ urlContext: {} });
|
|
76
|
+
return tools;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 建構共用 generateContent config */
|
|
80
|
+
_buildConfig(systemInstruction) {
|
|
81
|
+
const config = {
|
|
82
|
+
// Direct API(gemini-3 系列)與 Vertex AI 同樣使用 thinkingLevel 列舉
|
|
83
|
+
thinkingConfig: {
|
|
84
|
+
thinkingLevel: this.thinkingLevel,
|
|
85
|
+
includeThoughts: this.includeThoughts,
|
|
86
|
+
},
|
|
87
|
+
tools: this._buildTools(),
|
|
88
|
+
maxOutputTokens: this.maxOutputTokens,
|
|
89
|
+
temperature: this.temperature,
|
|
90
|
+
};
|
|
91
|
+
if (systemInstruction) config.systemInstruction = systemInstruction;
|
|
92
|
+
return config;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 向 Gemini 提問(非串流)
|
|
97
|
+
* @param {object} params
|
|
98
|
+
* @param {string} params.prompt - 使用者提問
|
|
99
|
+
* @param {string} [params.systemInstruction] - 系統指示
|
|
100
|
+
* @returns {Promise<object>} 結構化回應(含 sources 引證 + perf 效能)
|
|
101
|
+
*/
|
|
102
|
+
async generateContent({ prompt, systemInstruction }) {
|
|
103
|
+
const t0 = performance.now();
|
|
104
|
+
const response = await this.ai.models.generateContent({
|
|
105
|
+
model: this.model,
|
|
106
|
+
contents: prompt,
|
|
107
|
+
config: this._buildConfig(systemInstruction),
|
|
108
|
+
});
|
|
109
|
+
const latencyMs = performance.now() - t0;
|
|
110
|
+
|
|
111
|
+
const result = parseGeminiResponse(response);
|
|
112
|
+
if (result.sources.length > 0) {
|
|
113
|
+
result.sources = await resolveRedirectUrls(result.sources);
|
|
114
|
+
}
|
|
115
|
+
result.perf = calcPerf(result.usage, latencyMs, result.text);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 串流向 Gemini 提問(AsyncGenerator)
|
|
121
|
+
* yield { type: 'thinking', text } — 思考過程(即時串流)
|
|
122
|
+
* yield { type: 'text', text } — 回應文字(即時串流)
|
|
123
|
+
* yield { type: 'metadata', ...result } — 最終 grounding 引證 + 效能指標
|
|
124
|
+
*/
|
|
125
|
+
async *generateContentStream({ prompt, systemInstruction }) {
|
|
126
|
+
const t0 = performance.now();
|
|
127
|
+
const DBG = process.env.BH_DEBUG === '1';
|
|
128
|
+
|
|
129
|
+
if (DBG) {
|
|
130
|
+
process.stderr.write(`[DBG][gemini-api] model=${this.model} thinkingLevel=${this.thinkingLevel} grounding=${this.grounding} urlContext=${this.urlContext}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const streamResponse = await this.ai.models.generateContentStream({
|
|
134
|
+
model: this.model,
|
|
135
|
+
contents: prompt,
|
|
136
|
+
config: this._buildConfig(systemInstruction),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const stream = streamResponse[Symbol.asyncIterator]
|
|
140
|
+
? streamResponse
|
|
141
|
+
: streamResponse.stream || streamResponse;
|
|
142
|
+
|
|
143
|
+
let ttftMs = 0;
|
|
144
|
+
let isFirst = true;
|
|
145
|
+
let lastChunk = null;
|
|
146
|
+
let fullText = '';
|
|
147
|
+
|
|
148
|
+
for await (const chunk of stream) {
|
|
149
|
+
if (isFirst) { ttftMs = performance.now() - t0; isFirst = false; }
|
|
150
|
+
lastChunk = chunk;
|
|
151
|
+
|
|
152
|
+
for (const part of chunk.candidates?.[0]?.content?.parts || []) {
|
|
153
|
+
if (part.thought) {
|
|
154
|
+
yield { type: 'thinking', text: part.text };
|
|
155
|
+
} else if (part.text) {
|
|
156
|
+
fullText += part.text;
|
|
157
|
+
yield { type: 'text', text: part.text };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const latencyMs = performance.now() - t0;
|
|
163
|
+
if (lastChunk) {
|
|
164
|
+
const result = parseGeminiResponse(lastChunk);
|
|
165
|
+
// 補回串流累積的完整文字(最後一個 chunk 可能不含完整文字)
|
|
166
|
+
if (!result.text && fullText) result.text = fullText;
|
|
167
|
+
if (result.sources.length > 0) {
|
|
168
|
+
result.sources = await resolveRedirectUrls(result.sources);
|
|
169
|
+
}
|
|
170
|
+
result.perf = calcPerf(result.usage, latencyMs, result.text, ttftMs);
|
|
171
|
+
yield { type: 'metadata', ...result };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// lib/core/adapters/gemini-shared.mjs — Gemini adapter 共用純函式
|
|
2
|
+
//
|
|
3
|
+
// DRY:被 gemini-vertex.mjs 和 gemini-api.mjs 共同使用
|
|
4
|
+
// 原則:SOLID SRP — 純函式,無副作用,無狀態
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 平行解析 grounding redirect URL → 真實 URL
|
|
8
|
+
* 不跟隨 redirect,只讀 Location header(高效:1 次 HEAD request)
|
|
9
|
+
* @param {Array<{uri: string}>} sources - grounding 來源陣列
|
|
10
|
+
* @param {number} [timeoutMs=5000] - 每個請求的超時時間(ms)
|
|
11
|
+
* @returns {Promise<Array>} 解析後的來源陣列
|
|
12
|
+
*/
|
|
13
|
+
export async function resolveRedirectUrls(sources, timeoutMs = 5000) {
|
|
14
|
+
const tasks = sources.map(async (src) => {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(src.uri, {
|
|
17
|
+
method: 'HEAD',
|
|
18
|
+
redirect: 'manual',
|
|
19
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
20
|
+
});
|
|
21
|
+
const location = res.headers.get('location');
|
|
22
|
+
return { ...src, uri: location || src.uri };
|
|
23
|
+
} catch {
|
|
24
|
+
return src;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return Promise.all(tasks);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 從 Gemini API 回應(單一 candidate 或 chunk)中提取結構化結果
|
|
32
|
+
* 格式在 Vertex AI 和 Direct API 兩種模式下完全相同
|
|
33
|
+
* @param {object} response - API 原始回應
|
|
34
|
+
* @returns {object} 結構化結果
|
|
35
|
+
*/
|
|
36
|
+
export function parseGeminiResponse(response) {
|
|
37
|
+
const candidate = response.candidates?.[0];
|
|
38
|
+
if (!candidate) {
|
|
39
|
+
throw new Error('Gemini API 回應無 candidate');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 提取文字與思考
|
|
43
|
+
let text = '';
|
|
44
|
+
let thinking = '';
|
|
45
|
+
const parts = candidate.content?.parts || [];
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
if (part.thought) {
|
|
48
|
+
thinking += part.text + '\n';
|
|
49
|
+
} else if (part.text) {
|
|
50
|
+
text += part.text;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 提取 grounding 引證(Vertex AI / Direct API 格式相同)
|
|
55
|
+
const sources = [];
|
|
56
|
+
const gm = candidate.groundingMetadata;
|
|
57
|
+
if (gm?.groundingChunks) {
|
|
58
|
+
for (const chunk of gm.groundingChunks) {
|
|
59
|
+
if (chunk.web) {
|
|
60
|
+
sources.push({
|
|
61
|
+
uri: chunk.web.uri,
|
|
62
|
+
title: chunk.web.title || '',
|
|
63
|
+
domain: chunk.web.domain || '',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 提取 grounding supports(文字段落 → 來源對應)
|
|
70
|
+
const supports = [];
|
|
71
|
+
if (gm?.groundingSupports) {
|
|
72
|
+
for (const s of gm.groundingSupports) {
|
|
73
|
+
supports.push({
|
|
74
|
+
text: s.segment?.text || '',
|
|
75
|
+
startIndex: s.segment?.startIndex || 0,
|
|
76
|
+
endIndex: s.segment?.endIndex || 0,
|
|
77
|
+
chunkIndices: s.groundingChunkIndices || [],
|
|
78
|
+
confidenceScores: s.confidenceScores || [],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 搜尋查詢
|
|
84
|
+
const searchQueries = gm?.webSearchQueries || [];
|
|
85
|
+
|
|
86
|
+
// URL context 擷取結果
|
|
87
|
+
const urlContextResults = [];
|
|
88
|
+
const ucm = candidate.urlContextMetadata;
|
|
89
|
+
if (ucm?.urlMetadata) {
|
|
90
|
+
for (const entry of ucm.urlMetadata) {
|
|
91
|
+
urlContextResults.push({
|
|
92
|
+
url: entry.retrievedUrl,
|
|
93
|
+
status: entry.urlRetrievalStatus === 'URL_RETRIEVAL_STATUS_SUCCESS'
|
|
94
|
+
? 'success'
|
|
95
|
+
: entry.urlRetrievalStatus === 'URL_RETRIEVAL_STATUS_ERROR'
|
|
96
|
+
? 'error'
|
|
97
|
+
: 'unsafe',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Token 用量
|
|
103
|
+
const usage = {
|
|
104
|
+
promptTokens: response.usageMetadata?.promptTokenCount || 0,
|
|
105
|
+
candidatesTokens: response.usageMetadata?.candidatesTokenCount || 0,
|
|
106
|
+
thoughtsTokens: response.usageMetadata?.thoughtsTokenCount || 0,
|
|
107
|
+
totalTokens: response.usageMetadata?.totalTokenCount || 0,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
text: text.trim(),
|
|
112
|
+
thinking: thinking.trim(),
|
|
113
|
+
sources,
|
|
114
|
+
supports,
|
|
115
|
+
searchQueries,
|
|
116
|
+
urlContext: urlContextResults,
|
|
117
|
+
usage,
|
|
118
|
+
raw: response,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 計算效能指標(純函式)
|
|
124
|
+
* @param {object} usage - token 用量
|
|
125
|
+
* @param {number} latencyMs - API 回應延遲(ms)
|
|
126
|
+
* @param {string} text - 回應文字
|
|
127
|
+
* @param {number} [ttftMs=0] - Time To First Token (ms)
|
|
128
|
+
* @returns {object} 效能指標
|
|
129
|
+
*/
|
|
130
|
+
export function calcPerf(usage, latencyMs, text, ttftMs = 0) {
|
|
131
|
+
const latencySec = latencyMs / 1000;
|
|
132
|
+
const outputTokens = usage.candidatesTokens;
|
|
133
|
+
const thinkTokens = usage.thoughtsTokens;
|
|
134
|
+
const totalTokens = usage.totalTokens;
|
|
135
|
+
const promptTokens = usage.promptTokens;
|
|
136
|
+
|
|
137
|
+
const outputTps = latencySec > 0 ? outputTokens / latencySec : 0;
|
|
138
|
+
const totalTps = latencySec > 0 ? totalTokens / latencySec : 0;
|
|
139
|
+
const charsPerSec = latencySec > 0 ? text.length / latencySec : 0;
|
|
140
|
+
const afterTtftSec = (latencyMs - ttftMs) / 1000;
|
|
141
|
+
const streamTps = afterTtftSec > 0 ? outputTokens / afterTtftSec : 0;
|
|
142
|
+
|
|
143
|
+
const thinkRatio = totalTokens > 0 ? thinkTokens / totalTokens : 0;
|
|
144
|
+
const outputRatio = totalTokens > 0 ? outputTokens / totalTokens : 0;
|
|
145
|
+
const promptRatio = totalTokens > 0 ? promptTokens / totalTokens : 0;
|
|
146
|
+
const thinkToOutput = outputTokens > 0 ? thinkTokens / outputTokens : 0;
|
|
147
|
+
const charsPerToken = outputTokens > 0 ? text.length / outputTokens : 0;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
latencyMs: Math.round(latencyMs),
|
|
151
|
+
latencySec: +latencySec.toFixed(2),
|
|
152
|
+
ttftMs: Math.round(ttftMs),
|
|
153
|
+
ttftSec: +(ttftMs / 1000).toFixed(2),
|
|
154
|
+
outputTokensPerSec: +outputTps.toFixed(1),
|
|
155
|
+
totalTokensPerSec: +totalTps.toFixed(1),
|
|
156
|
+
charsPerSec: +charsPerSec.toFixed(1),
|
|
157
|
+
streamTps: +streamTps.toFixed(1),
|
|
158
|
+
thinkRatio: +thinkRatio.toFixed(3),
|
|
159
|
+
outputRatio: +outputRatio.toFixed(3),
|
|
160
|
+
promptRatio: +promptRatio.toFixed(3),
|
|
161
|
+
thinkToOutputRatio: +thinkToOutput.toFixed(2),
|
|
162
|
+
charsPerToken: +charsPerToken.toFixed(2),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// lib/core/adapters/gemini-vertex.mjs — Gemini 3 Flash Preview Vertex AI adapter
|
|
2
|
+
// 唯一指定模型: gemini-3-flash-preview
|
|
3
|
+
// 功能: thinking (MEDIUM) + grounding (Google Search) + url context
|
|
4
|
+
// 原則: SOLID SRP — 此 adapter 只負責 Vertex AI Gemini API 呼叫
|
|
5
|
+
|
|
6
|
+
import { GoogleGenAI } from '@google/genai';
|
|
7
|
+
import { parseGeminiResponse, calcPerf, resolveRedirectUrls } from './gemini-shared.mjs';
|
|
8
|
+
import { BaseAdapter } from './base.mjs';
|
|
9
|
+
|
|
10
|
+
/** 預設設定常數 */
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
model: 'gemini-3-flash-preview',
|
|
13
|
+
thinkingLevel: 'MEDIUM',
|
|
14
|
+
includeThoughts: true,
|
|
15
|
+
grounding: true,
|
|
16
|
+
urlContext: true,
|
|
17
|
+
maxOutputTokens: 8192,
|
|
18
|
+
temperature: 1.0,
|
|
19
|
+
apiVersion: 'v1beta1',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gemini Vertex AI Adapter
|
|
24
|
+
* 遵循 SOLID 原則:單一職責 — 封裝 Vertex AI Gemini API 呼叫
|
|
25
|
+
*/
|
|
26
|
+
export class GeminiVertexAdapter extends BaseAdapter {
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {string} opts.project - GCP 專案 ID(必填或透過環境變數)
|
|
30
|
+
* @param {string} [opts.location='global'] - GCP 區域
|
|
31
|
+
* @param {string} [opts.thinkingLevel='MEDIUM'] - 思考等級
|
|
32
|
+
* @param {boolean} [opts.grounding=true] - 啟用 Google Search 接地
|
|
33
|
+
* @param {boolean} [opts.urlContext=true] - 啟用 URL 內容擷取
|
|
34
|
+
* @param {boolean} [opts.includeThoughts=true] - 回傳思考摘要
|
|
35
|
+
*/
|
|
36
|
+
constructor(opts = {}) {
|
|
37
|
+
super();
|
|
38
|
+
const project = opts.project || process.env.GOOGLE_CLOUD_PROJECT;
|
|
39
|
+
const location = opts.location || process.env.GOOGLE_CLOUD_LOCATION || 'global';
|
|
40
|
+
|
|
41
|
+
if (!project) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'GeminiVertexAdapter: 需要 GCP 專案 ID。' +
|
|
44
|
+
'請設定 GOOGLE_CLOUD_PROJECT 環境變數或傳入 project 參數。'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.ai = new GoogleGenAI({
|
|
49
|
+
vertexai: true,
|
|
50
|
+
project,
|
|
51
|
+
location,
|
|
52
|
+
apiVersion: opts.apiVersion || DEFAULTS.apiVersion,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.model = DEFAULTS.model;
|
|
56
|
+
this.thinkingLevel = (opts.thinkingLevel || DEFAULTS.thinkingLevel).toUpperCase();
|
|
57
|
+
this.includeThoughts = opts.includeThoughts ?? DEFAULTS.includeThoughts;
|
|
58
|
+
this.grounding = opts.grounding ?? DEFAULTS.grounding;
|
|
59
|
+
this.urlContext = opts.urlContext ?? DEFAULTS.urlContext;
|
|
60
|
+
this.maxOutputTokens = opts.maxOutputTokens || DEFAULTS.maxOutputTokens;
|
|
61
|
+
this.temperature = opts.temperature ?? DEFAULTS.temperature;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 建構 tools 陣列
|
|
66
|
+
* @returns {Array} tools config
|
|
67
|
+
*/
|
|
68
|
+
_buildTools() {
|
|
69
|
+
const tools = [];
|
|
70
|
+
if (this.grounding) tools.push({ googleSearch: {} });
|
|
71
|
+
if (this.urlContext) tools.push({ urlContext: {} });
|
|
72
|
+
return tools;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// DRY: _parseResponse 已移至 gemini-shared.mjs 的 parseGeminiResponse()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 向 Gemini 提問
|
|
79
|
+
* @param {object} params
|
|
80
|
+
* @param {string} params.prompt - 使用者提問
|
|
81
|
+
* @param {string} [params.systemInstruction] - 系統指示
|
|
82
|
+
* @returns {Promise<object>} 結構化回應
|
|
83
|
+
*/
|
|
84
|
+
async generateContent({ prompt, systemInstruction }) {
|
|
85
|
+
const config = {
|
|
86
|
+
thinkingConfig: {
|
|
87
|
+
thinkingLevel: this.thinkingLevel,
|
|
88
|
+
includeThoughts: this.includeThoughts,
|
|
89
|
+
},
|
|
90
|
+
tools: this._buildTools(),
|
|
91
|
+
maxOutputTokens: this.maxOutputTokens,
|
|
92
|
+
temperature: this.temperature,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (systemInstruction) {
|
|
96
|
+
config.systemInstruction = systemInstruction;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const params = {
|
|
100
|
+
model: this.model,
|
|
101
|
+
contents: prompt,
|
|
102
|
+
config,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const t0 = performance.now();
|
|
106
|
+
const response = await this.ai.models.generateContent(params);
|
|
107
|
+
const latencyMs = performance.now() - t0;
|
|
108
|
+
|
|
109
|
+
const result = parseGeminiResponse(response);
|
|
110
|
+
// 平行解析 grounding redirect URL → 真實 URL
|
|
111
|
+
if (result.sources.length > 0) {
|
|
112
|
+
result.sources = await resolveRedirectUrls(result.sources);
|
|
113
|
+
}
|
|
114
|
+
result.perf = calcPerf(result.usage, latencyMs, result.text);
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 串流向 Gemini 提問(AsyncGenerator)
|
|
120
|
+
* 每個文字 chunk yield { type: 'text', text }
|
|
121
|
+
* 最後 yield { type: 'metadata', ...結構化結果 }(含 grounding、usage、perf)
|
|
122
|
+
* @param {object} params
|
|
123
|
+
* @param {string} params.prompt - 使用者提問
|
|
124
|
+
* @param {string} [params.systemInstruction] - 系統指示
|
|
125
|
+
* @yields {{ type: 'text', text: string } | { type: 'metadata', ... }}
|
|
126
|
+
*/
|
|
127
|
+
async *generateContentStream({ prompt, systemInstruction }) {
|
|
128
|
+
const config = {
|
|
129
|
+
thinkingConfig: {
|
|
130
|
+
thinkingLevel: this.thinkingLevel,
|
|
131
|
+
includeThoughts: this.includeThoughts,
|
|
132
|
+
},
|
|
133
|
+
tools: this._buildTools(),
|
|
134
|
+
maxOutputTokens: this.maxOutputTokens,
|
|
135
|
+
temperature: this.temperature,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (systemInstruction) {
|
|
139
|
+
config.systemInstruction = systemInstruction;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const params = {
|
|
143
|
+
model: this.model,
|
|
144
|
+
contents: prompt,
|
|
145
|
+
config,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const t0 = performance.now();
|
|
149
|
+
const DBG = process.env.BH_DEBUG === '1';
|
|
150
|
+
if (DBG) {
|
|
151
|
+
process.stderr.write(`[DBG] === 實際發送的 API 參數 ===\n`);
|
|
152
|
+
process.stderr.write(`[DBG] model: ${params.model}\n`);
|
|
153
|
+
process.stderr.write(`[DBG] systemInstruction: ${config.systemInstruction ? JSON.stringify(config.systemInstruction).slice(0, 300) : '(無)'}\n`);
|
|
154
|
+
process.stderr.write(`[DBG] contents: ${JSON.stringify(params.contents).slice(0, 200)}\n`);
|
|
155
|
+
process.stderr.write(`[DBG] tools: ${JSON.stringify(config.tools)}\n`);
|
|
156
|
+
process.stderr.write(`[DBG] thinkingLevel: ${config.thinkingConfig.thinkingLevel}\n`);
|
|
157
|
+
process.stderr.write(`[DBG] =============================\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const streamResponse = await this.ai.models.generateContentStream(params);
|
|
161
|
+
|
|
162
|
+
if (DBG) {
|
|
163
|
+
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
|
164
|
+
process.stderr.write(`[DBG] SDK 回傳 streamResponse (${dt}s) type=${typeof streamResponse} keys=${Object.keys(streamResponse || {}).join(',')}\n`);
|
|
165
|
+
// 檢查 streamResponse 的可迭代性
|
|
166
|
+
const isAsyncIter = streamResponse && typeof streamResponse[Symbol.asyncIterator] === 'function';
|
|
167
|
+
const hasStream = streamResponse && typeof streamResponse.stream === 'object';
|
|
168
|
+
process.stderr.write(`[DBG] asyncIterable=${isAsyncIter} hasStream=${hasStream}\n`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SDK 可能回傳 { stream: AsyncIterable } 或直接 AsyncIterable
|
|
172
|
+
const stream = streamResponse[Symbol.asyncIterator]
|
|
173
|
+
? streamResponse
|
|
174
|
+
: streamResponse.stream || streamResponse;
|
|
175
|
+
|
|
176
|
+
let ttftMs = 0;
|
|
177
|
+
let isFirst = true;
|
|
178
|
+
let lastChunk = null;
|
|
179
|
+
let chunkIdx = 0;
|
|
180
|
+
|
|
181
|
+
for await (const chunk of stream) {
|
|
182
|
+
const now = performance.now();
|
|
183
|
+
if (isFirst) {
|
|
184
|
+
ttftMs = now - t0;
|
|
185
|
+
isFirst = false;
|
|
186
|
+
}
|
|
187
|
+
lastChunk = chunk;
|
|
188
|
+
|
|
189
|
+
if (DBG) {
|
|
190
|
+
const dt = ((now - t0) / 1000).toFixed(2);
|
|
191
|
+
const parts = chunk.candidates?.[0]?.content?.parts || [];
|
|
192
|
+
const partsSummary = parts.map((p, i) => {
|
|
193
|
+
const type = p.thought ? 'thought' : p.text ? 'text' : 'other';
|
|
194
|
+
const len = (p.text || '').length;
|
|
195
|
+
return `${type}(${len})`;
|
|
196
|
+
}).join(', ');
|
|
197
|
+
const hasGrounding = !!chunk.candidates?.[0]?.groundingMetadata;
|
|
198
|
+
const groundingCount = chunk.candidates?.[0]?.groundingMetadata?.groundingChunks?.length || 0;
|
|
199
|
+
const hasUsage = !!chunk.usageMetadata;
|
|
200
|
+
process.stderr.write(`[DBG] chunk#${chunkIdx} @${dt}s parts=[${partsSummary}] groundingMeta=${hasGrounding}${hasGrounding ? `(${groundingCount}筆)` : ''} usage=${hasUsage}\n`);
|
|
201
|
+
}
|
|
202
|
+
chunkIdx++;
|
|
203
|
+
|
|
204
|
+
const parts = chunk.candidates?.[0]?.content?.parts || [];
|
|
205
|
+
for (const part of parts) {
|
|
206
|
+
if (part.thought) {
|
|
207
|
+
yield { type: 'thinking', text: part.text };
|
|
208
|
+
} else if (part.text) {
|
|
209
|
+
yield { type: 'text', text: part.text };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (DBG) {
|
|
215
|
+
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
|
216
|
+
process.stderr.write(`[DBG] 串流結束 共${chunkIdx}個chunk @${dt}s ttft=${(ttftMs/1000).toFixed(2)}s\n`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const latencyMs = performance.now() - t0;
|
|
220
|
+
|
|
221
|
+
if (lastChunk) {
|
|
222
|
+
const result = parseGeminiResponse(lastChunk);
|
|
223
|
+
if (result.sources.length > 0) {
|
|
224
|
+
result.sources = await resolveRedirectUrls(result.sources);
|
|
225
|
+
}
|
|
226
|
+
result.perf = calcPerf(result.usage, latencyMs, result.text, ttftMs);
|
|
227
|
+
yield { type: 'metadata', ...result };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// DRY: _calcPerf 已移至 gemini-shared.mjs 的 calcPerf()
|
|
232
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// lib/core/adapters/local.mjs — 本地 LLM adapter
|
|
2
|
+
// TODO: 實作本地 LLM(如 ollama)呼叫
|
|
3
|
+
|
|
4
|
+
export class LocalAdapter {
|
|
5
|
+
constructor(config = {}) {
|
|
6
|
+
this.endpoint = config.endpoint || 'http://localhost:11434';
|
|
7
|
+
// TODO: 初始化本地 LLM 連線
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async chat(messages) {
|
|
11
|
+
throw new Error('LocalAdapter.chat() 尚未實作');
|
|
12
|
+
}
|
|
13
|
+
}
|