From cd4159fec905d5a8e98353e13e80b28b395a426c Mon Sep 17 00:00:00 2001 From: posimai Date: Mon, 20 Apr 2026 22:51:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(together):=20TOGETHER=5FGEMINI=5FAPI=5FKEY?= =?UTF-8?q?=20=E3=81=A7=20Brain=20=E3=81=A8=20Gemini=20quota=20=E3=82=92?= =?UTF-8?q?=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TOGETHER_GEMINI_API_KEY が設定されている場合はそちらを使い、 未設定時は既存の GEMINI_API_KEY にフォールバック。 posimai-brain と posimai-together の quota が独立して管理できるようになる。 Co-Authored-By: Claude Sonnet 4.6 --- server.js | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 79b59ecc..af534b59 100644 --- a/server.js +++ b/server.js @@ -192,6 +192,9 @@ pool.on('error', (err) => { // ── Gemini ──────────────────────────────── const genAI = process.env.GEMINI_API_KEY ? new GoogleGenerativeAI(process.env.GEMINI_API_KEY) : null; +// Together アーカイブ専用キー(未設定時は genAI にフォールバック) +const togetherGenAI = process.env.TOGETHER_GEMINI_API_KEY + ? new GoogleGenerativeAI(process.env.TOGETHER_GEMINI_API_KEY) : genAI; // ── API Key 認証 ────────────────────────── @@ -1984,6 +1987,43 @@ ${excerpt} } }); + // POST /ai/generate — 汎用 Gemini プロキシ(Digest / Think 用。認証必須・レート制限あり) + r.post('/ai/generate', authMiddleware, async (req, res) => { + if (!genAI) return res.status(503).json({ error: 'AI not configured' }); + if (!checkRateLimit('ai_generate', req.userId, 30, 60 * 60 * 1000)) { + return res.status(429).json({ error: 'AI利用回数が上限に達しました。1時間後に再試行してください' }); + } + const { contents, systemPrompt, config = {} } = req.body || {}; + if (!Array.isArray(contents) || !contents.length) { + return res.status(400).json({ error: 'contents required' }); + } + if (JSON.stringify(contents).length > 60000) { + return res.status(400).json({ error: 'contents too large' }); + } + try { + const modelOpts = { model: 'gemini-2.5-flash' }; + if (systemPrompt && typeof systemPrompt === 'string') { + modelOpts.systemInstruction = systemPrompt; + } + const model = genAI.getGenerativeModel(modelOpts); + const generationConfig = { + maxOutputTokens: Math.min(config.maxOutputTokens || 500, 1000), + temperature: config.temperature ?? 0.5 + }; + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), 15000) + ); + const result = await Promise.race([ + model.generateContent({ contents, generationConfig }), + timeoutPromise + ]); + res.json({ text: result.response.text() }); + } catch (e) { + console.error('[ai/generate]', e.message); + res.status(500).json({ error: 'AI generation failed' }); + } + }); + r.post('/journal/upload', authMiddleware, (req, res) => { try { const { base64 } = req.body || {}; @@ -2582,12 +2622,12 @@ ${excerpt} let summary = null; let tags = []; - if (genAI && fullContent) { + if (togetherGenAI && fullContent) { try { // 最初の ## 見出し以降を本文とみなし 4000 字を Gemini に渡す const bodyStart = fullContent.search(/^#{1,2}\s/m); const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000); - const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); + const model = togetherGenAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); const prompt = `以下の記事を分析して、JSONのみを返してください(コードブロック不要)。\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す具体的な日本語タグを2〜4個。「その他」は絶対に使わないこと。内容が不明な場合でも最も近いカテゴリを選ぶ(例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン, ライフスタイル, 教育, 環境, 医療, 法律, 文化, 歴史)\n\n記事:\n${excerpt}`; const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000)); const result = await Promise.race([model.generateContent(prompt), timeoutP]);