Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 画像の自動生成 |
Expand Down Expand Up @@ -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`)

## お問い合わせフォーム

Expand Down
172 changes: 130 additions & 42 deletions functions/api/ai-contact.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,59 @@
type Env = {
OPENAI_API_KEY?: string
OPENAI_MODEL?: string
AI?: AiBinding
CLOUDFLARE_AI_MODEL?: string
CLOUDFLARE_AI_REASONING_EFFORT?: string
}

type PagesContext = {
request: Request
env: Env
}

type OpenAIResponse = {
type AiBinding = {
run: (
model: string,
input: WorkersAiTextGenerationInput,
) => Promise<WorkersAiTextGenerationResponse | string>
}

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
}
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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 || [])
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/components/AiChatWidget.astro
Original file line number Diff line number Diff line change
Expand Up @@ -285,15 +285,15 @@ 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')
code.textContent = match[6]
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]))
Expand Down
18 changes: 15 additions & 3 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down