diff --git a/README.md b/README.md index c22afc9..bd3d808 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Acecore(エースコア)公式Webサイト。 | [Cloudflare Pages](https://pages.cloudflare.com/) | ホスティング・CDN | | [Cloudflare Images Transformations](https://developers.cloudflare.com/images/transform-images/) | 外部画像の自動最適化(`/cdn-cgi/image/`) | | [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) | お問い合わせフォームのメール送信 | -| [OpenAI API](https://platform.openai.com/docs/api-reference/responses/create) | 問い合わせ前の AI FAQ アシスタント | +| [Cloudflare Workers AI](https://developers.cloudflare.com/workers-ai/) | 問い合わせ前の AI FAQ アシスタント | | [Pagefind](https://pagefind.app/) | 静的全文検索 | | [Sveltia CMS](https://sveltiacms.app/) | Git ベース CMS(ブログ・ページ文言管理) | | [satori](https://github.com/vercel/satori) + [sharp](https://sharp.pixelplumbing.com/) | OG 画像の自動生成 | @@ -188,12 +188,13 @@ Sveltia CMS は日本語ソース記事と日本語の固定ページ文言を サイト全体に右下の AI チャットを表示し、お問い合わせページでは FAQ の後に AI チャットを開ける導線を配置しています。AI で答えきれない見積りや正式な相談はフォームへ、短い相談や教室関連は LINE に自然につなげます。メール・電話は常時露出せず、問い合わせページ下部の「直接やりとりしたい場合」や AI が必要と判断した場合の案内に限定します。 -`functions/api/ai-contact.ts` の Cloudflare Pages Function から OpenAI Responses API を呼び出します。ブラウザには API キーを渡しません。 +`functions/api/ai-contact.ts` の Cloudflare Pages Function から Cloudflare Workers AI binding を呼び出します。既定では GLM 5.2 (`@cf/zai-org/glm-5.2`) を reasoning effort `low` で使います。ブラウザには AI 実行用のキーを渡しません。 Cloudflare Pages 側で以下を設定してください。 -- `OPENAI_API_KEY`: OpenAI API キー -- `OPENAI_MODEL`: 使用モデル(未設定時は `gpt-5.4-mini`) +- Workers AI binding: `AI` +- `CLOUDFLARE_AI_MODEL`: 使用モデル(未設定時は `@cf/zai-org/glm-5.2`) +- `CLOUDFLARE_AI_REASONING_EFFORT`: 推論 effort(未設定時は `low`) ## お問い合わせフォーム diff --git a/functions/api/ai-contact.ts b/functions/api/ai-contact.ts index 265f917..da80dd4 100644 --- a/functions/api/ai-contact.ts +++ b/functions/api/ai-contact.ts @@ -1,6 +1,7 @@ type Env = { - OPENAI_API_KEY?: string - OPENAI_MODEL?: string + AI?: AiBinding + CLOUDFLARE_AI_MODEL?: string + CLOUDFLARE_AI_REASONING_EFFORT?: string } type PagesContext = { @@ -8,15 +9,51 @@ type PagesContext = { env: Env } -type OpenAIResponse = { +type AiBinding = { + run: ( + model: string, + input: WorkersAiTextGenerationInput, + ) => Promise +} + +type WorkersAiMessage = { + role: 'system' | 'user' | 'assistant' + content: string +} + +type WorkersAiTextGenerationInput = { + messages: WorkersAiMessage[] + max_completion_tokens: number + reasoning_effort: WorkersAiReasoningEffort + temperature: number +} + +type WorkersAiReasoningEffort = 'low' | 'medium' | 'high' + +type WorkersAiResponseContent = { + type?: string + text?: string +} + +type WorkersAiChoice = { + text?: string + message?: { + content?: string | WorkersAiResponseContent[] + } + delta?: { + content?: string + } +} + +type WorkersAiTextGenerationResponse = { + response?: string output_text?: string + choices?: WorkersAiChoice[] output?: Array<{ type?: string - content?: Array<{ - type?: string - text?: string - }> + content?: WorkersAiResponseContent[] }> + result?: WorkersAiTextGenerationResponse error?: { message?: string } @@ -33,10 +70,10 @@ type AiContactPayload = { messages?: ChatMessage[] } -const OPENAI_RESPONSES_ENDPOINT = 'https://api.openai.com/v1/responses' const SCHOOLS_ORIGIN = 'https://schools.acecore.net' const SYSTEMS_ORIGIN = 'https://systems.acecore.net' -const DEFAULT_MODEL = 'gpt-5.4-mini' +const DEFAULT_CLOUDFLARE_AI_MODEL = '@cf/zai-org/glm-5.2' +const DEFAULT_CLOUDFLARE_AI_REASONING_EFFORT: WorkersAiReasoningEffort = 'low' const MAX_QUESTION_LENGTH = 800 const MAX_HISTORY_MESSAGES = 8 const MAX_CONVERSATION_LENGTH = 3200 @@ -346,7 +383,7 @@ export const onRequestPost = async ({ ) } - if (!env.OPENAI_API_KEY) { + if (!env.AI) { return jsonResponse( { ok: false, answer: getLocalizedMessage(locale, 'unconfigured') }, 503, @@ -377,46 +414,60 @@ export const onRequestPost = async ({ ) } - const response = await fetch(OPENAI_RESPONSES_ENDPOINT, { - method: 'POST', - headers: { - Authorization: `Bearer ${env.OPENAI_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: env.OPENAI_MODEL || DEFAULT_MODEL, - instructions: [ - 'You are Acecore website chat assistant.', - `Answer in ${localeSettings.languageName}. The visitor locale code is ${locale}.`, - 'Answer ordinary questions about Acecore using the public site context below.', - 'Keep answers concise, practical, and helpful for choosing the next action.', - 'Use the localized Acecore paths and external Acecore service URLs listed in the context exactly. Do not replace localized paths with default-language URLs.', - 'Use simple Markdown when it improves readability: short paragraphs, bullet lists, and **bold** for important service names. When a relevant Acecore page or contact path exists, make the first useful mention a Markdown link using the URLs in the context. Include links in answers about service selection, estimates, schools, works, contact options, or next steps. Do not link every repeated mention. Do not use raw HTML or tables. Prefer bullet lists over long arrow chains.', - 'Do not invent pricing, timelines, contracts, guarantees, or private contact details.', - 'If a request needs a human decision, detailed estimate, formal reply, urgent help, or support beyond the public site context, say the AI cannot decide that and guide the visitor to the best contact option.', - `Use the localized ${localeSettings.contactFormLabel} for detailed project consultations and estimates. Mention ${localeSettings.lineLabel} for short consultations and school-related messages. If the conversation appears unresolved or the visitor asks for direct human contact, add a compact direct-contact line with [${localeSettings.emailLabel}](mailto:info@acecore.net) or [${localeSettings.phoneLabel}](tel:05088902788) only when appropriate.`, - buildAcecoreContext(locale), - ].join('\n'), - input: `Visitor locale: ${locale}\nConversation:\n${conversationInput}`, - max_output_tokens: 360, - }), - }) - - const result = (await response - .json() - .catch(() => null)) as OpenAIResponse | null + let result: WorkersAiTextGenerationResponse | string | null + try { + result = await env.AI.run( + env.CLOUDFLARE_AI_MODEL || DEFAULT_CLOUDFLARE_AI_MODEL, + { + messages: [ + { + role: 'system', + content: [ + 'You are Acecore website chat assistant.', + `Answer in ${localeSettings.languageName}. The visitor locale code is ${locale}.`, + 'Answer ordinary questions about Acecore using the public site context below.', + 'Keep answers concise, practical, and helpful for choosing the next action.', + 'Use the localized Acecore paths and external Acecore service URLs listed in the context exactly. Do not replace localized paths with default-language URLs.', + 'Use simple Markdown when it improves readability: short paragraphs, bullet lists, and **bold** for important service names. When a relevant Acecore page or contact path exists, make the first useful mention a Markdown link using the URLs in the context. Include links in answers about service selection, estimates, schools, works, contact options, or next steps. Do not link every repeated mention. Do not use raw HTML or tables. Prefer bullet lists over long arrow chains.', + 'Do not invent pricing, timelines, contracts, guarantees, or private contact details.', + 'If a request needs a human decision, detailed estimate, formal reply, urgent help, or support beyond the public site context, say the AI cannot decide that and guide the visitor to the best contact option.', + `Use the localized ${localeSettings.contactFormLabel} for detailed project consultations and estimates. Mention ${localeSettings.lineLabel} for short consultations and school-related messages. If the conversation appears unresolved or the visitor asks for direct human contact, add a compact direct-contact line with [${localeSettings.emailLabel}](mailto:info@acecore.net) or [${localeSettings.phoneLabel}](tel:05088902788) only when appropriate.`, + buildAcecoreContext(locale), + ].join('\n'), + }, + { + role: 'user', + content: `Visitor locale: ${locale}\nConversation:\n${conversationInput}`, + }, + ], + max_completion_tokens: 360, + reasoning_effort: normalizeReasoningEffort( + env.CLOUDFLARE_AI_REASONING_EFFORT, + ), + temperature: 0.2, + }, + ) + } catch { + return jsonResponse( + { + ok: false, + answer: getLocalizedMessage(locale, 'failed'), + }, + 502, + ) + } - if (!response.ok) { + if (typeof result !== 'string' && result?.error) { return jsonResponse( { ok: false, answer: getLocalizedMessage(locale, 'failed'), }, - response.status, + 502, ) } - const answer = extractOutputText(result).trim() + const answer = extractWorkersAiText(result).trim() return jsonResponse({ ok: true, answer: answer || getLocalizedMessage(locale, 'emptyAnswer'), @@ -525,9 +576,30 @@ function getLocalizedMessage( return LOCALE_SETTINGS[locale].messages[key] } -function extractOutputText(result: OpenAIResponse | null): string { +function normalizeReasoningEffort(value: unknown): WorkersAiReasoningEffort { + const effort = String(value || DEFAULT_CLOUDFLARE_AI_REASONING_EFFORT) + .trim() + .toLowerCase() + + return effort === 'medium' || effort === 'high' + ? effort + : DEFAULT_CLOUDFLARE_AI_REASONING_EFFORT +} + +function extractWorkersAiText( + result: WorkersAiTextGenerationResponse | string | null, +): string { if (!result) return '' + if (typeof result === 'string') return result + if (typeof result.response === 'string') return result.response if (typeof result.output_text === 'string') return result.output_text + if (result.result) return extractWorkersAiText(result.result) + + const choicesText = (result.choices || []) + .map(extractChoiceText) + .filter(Boolean) + .join('\n') + if (choicesText) return choicesText return (result.output || []) .flatMap((item) => item.content || []) @@ -536,6 +608,22 @@ function extractOutputText(result: OpenAIResponse | null): string { .join('\n') } +function extractChoiceText(choice: WorkersAiChoice): string { + if (typeof choice.text === 'string') return choice.text + if (typeof choice.delta?.content === 'string') return choice.delta.content + + const content = choice.message?.content + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((part) => part.text || '') + .filter(Boolean) + .join('\n') + } + + return '' +} + function buildConversationInput(payload: AiContactPayload): string { const messages = Array.isArray(payload.messages) ? payload.messages : [] const lines = messages diff --git a/src/components/AiChatWidget.astro b/src/components/AiChatWidget.astro index 021ccee..b8f8d41 100644 --- a/src/components/AiChatWidget.astro +++ b/src/components/AiChatWidget.astro @@ -285,7 +285,7 @@ const phoneParts = SITE.phone.split('-') parent.appendChild(link) } else if (match[4] || match[5]) { const strong = document.createElement('strong') - strong.textContent = match[4] || match[5] + appendInlineMarkdown(strong, match[4] || match[5]) parent.appendChild(strong) } else if (match[6]) { const code = document.createElement('code') @@ -293,7 +293,7 @@ const phoneParts = SITE.phone.split('-') parent.appendChild(code) } else if (match[7] || match[8]) { const emphasis = document.createElement('em') - emphasis.textContent = match[7] || match[8] + appendInlineMarkdown(emphasis, match[7] || match[8]) parent.appendChild(emphasis) } else { parent.appendChild(document.createTextNode(match[0])) diff --git a/wrangler.jsonc b/wrangler.jsonc index ebc7e85..86e561b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,9 +3,13 @@ "name": "acecore-net", "pages_build_output_dir": "./dist", "compatibility_date": "2026-03-12", + "ai": { + "binding": "AI", + }, "vars": { "CLOUDFLARE_ACCOUNT_ID": "db9b62f409f463da7acbcc374b8385d0", - "OPENAI_MODEL": "gpt-5.4-mini", + "CLOUDFLARE_AI_MODEL": "@cf/zai-org/glm-5.2", + "CLOUDFLARE_AI_REASONING_EFFORT": "low", "COMMENT_ALLOWED_HOSTNAMES": "acecore.net,www.acecore.net,acecore-net.pages.dev,localhost,127.0.0.1", "CONTACT_FROM_EMAIL": "noreply@acecore.net", "CONTACT_TO_EMAIL": "info@acecore.net", @@ -21,9 +25,13 @@ ], "env": { "preview": { + "ai": { + "binding": "AI", + }, "vars": { "CLOUDFLARE_ACCOUNT_ID": "db9b62f409f463da7acbcc374b8385d0", - "OPENAI_MODEL": "gpt-5.4-mini", + "CLOUDFLARE_AI_MODEL": "@cf/zai-org/glm-5.2", + "CLOUDFLARE_AI_REASONING_EFFORT": "low", "COMMENT_ALLOWED_HOSTNAMES": "acecore.net,www.acecore.net,acecore-net.pages.dev", "CONTACT_FROM_EMAIL": "noreply@acecore.net", "CONTACT_TO_EMAIL": "info@acecore.net", @@ -39,9 +47,13 @@ ], }, "production": { + "ai": { + "binding": "AI", + }, "vars": { "CLOUDFLARE_ACCOUNT_ID": "db9b62f409f463da7acbcc374b8385d0", - "OPENAI_MODEL": "gpt-5.4-mini", + "CLOUDFLARE_AI_MODEL": "@cf/zai-org/glm-5.2", + "CLOUDFLARE_AI_REASONING_EFFORT": "low", "COMMENT_ALLOWED_HOSTNAMES": "acecore.net,www.acecore.net,acecore-net.pages.dev", "CONTACT_FROM_EMAIL": "noreply@acecore.net", "CONTACT_TO_EMAIL": "info@acecore.net",