@folio-agent/handler 0.2.1 → 0.3.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 CHANGED
@@ -9,7 +9,10 @@
9
9
  ```bash
10
10
  npm install @folio-agent/handler
11
11
 
12
- # ビルド時: 知識ファイルの生成
12
+ # 対話ウィザードで初期設定(config・テーマCSS・APIルート雛形などを生成)
13
+ npx folio-agent-init
14
+
15
+ # ビルド時: 知識ファイルの生成(ウィザードが build スクリプトに追記する)
13
16
  npx folio-agent-ingest folio-agent.config.json knowledge.json
14
17
  ```
15
18
 
@@ -28,4 +31,6 @@ export default {
28
31
  };
29
32
  ```
30
33
 
34
+ `createChatHandler` / `createGeminiGenerator` の両方に `language: "en"` を渡すと、定型文・ルーティングキーワード・システムプロンプトが英語になる(既定は `ja`)。
35
+
31
36
  D1 スキーマは `migrations/0001_init.sql` を `wrangler d1 migrations apply` で適用する。
@@ -1,10 +1,11 @@
1
- import type { ChatRoute } from "./types.js";
2
- export declare function buildSystemPrompt(knowledge: string, route: Exclude<ChatRoute, "rate_limited">, contactUrl?: string): string;
1
+ import type { ChatRoute, Language } from "./types.js";
2
+ export declare function buildSystemPrompt(knowledge: string, route: Exclude<ChatRoute, "rate_limited">, contactUrl?: string, language?: Language): string;
3
3
  export interface GeminiGeneratorConfig {
4
4
  apiKey: string;
5
5
  knowledge: string;
6
6
  model?: string;
7
7
  contactUrl?: string;
8
+ language?: Language;
8
9
  }
9
10
  export type GenerateAnswerFn = (input: string, route: ChatRoute) => Promise<string>;
10
11
  export declare function createGeminiGenerator(config: GeminiGeneratorConfig): GenerateAnswerFn;
@@ -1,31 +1,57 @@
1
1
  import { GoogleGenAI } from "@google/genai";
2
- const COMMON_PREAMBLE = "あなたはポートフォリオサイト作者の代理として応対する受付エージェントです。" +
3
- "丁寧だが簡潔で自然な口語で話し、「お問い合わせいただきありがとうございます」のような定型の前置き挨拶はしません。" +
4
- "回答は原則3〜4文以内にまとめてください。\n\n" +
5
- "以下の知識に書かれていることだけから答えてください。書かれていないことは推測・創作せず、" +
6
- "「その点はサイトに記載がない」と伝えたうえでContactページを案内してください。" +
7
- "用語の正式名称・展開形も、知識に書かれていなければ創作しないでください。\n\n" +
8
- "Markdown記法(`**`・`#`・`-`のリストなど)は使わず、プレーンテキストで答えてください。" +
9
- "回答が長くなる場合は、2〜4文ごとに空行を挟んで段落を分けてください。";
2
+ import { DEFAULT_LANGUAGE } from "./types.js";
3
+ const COMMON_PREAMBLE = {
4
+ ja: "あなたはポートフォリオサイト作者の代理として応対する受付エージェントです。" +
5
+ "丁寧だが簡潔で自然な口語で話し、「お問い合わせいただきありがとうございます」のような定型の前置き挨拶はしません。" +
6
+ "回答は原則3〜4文以内にまとめてください。\n\n" +
7
+ "以下の知識に書かれていることだけから答えてください。書かれていないことは推測・創作せず、" +
8
+ "「その点はサイトに記載がない」と伝えたうえでContactページを案内してください。" +
9
+ "用語の正式名称・展開形も、知識に書かれていなければ創作しないでください。\n\n" +
10
+ "Markdown記法(`**`・`#`・`-`のリストなど)は使わず、プレーンテキストで答えてください。" +
11
+ "回答が長くなる場合は、2〜4文ごとに空行を挟んで段落を分けてください。",
12
+ en: "You are a receptionist agent responding on behalf of the portfolio site's author. " +
13
+ 'Speak politely but concisely and naturally, without formulaic openers such as "Thank you for reaching out." ' +
14
+ "Keep your answers to about 3-4 sentences.\n\n" +
15
+ "Only answer based on the knowledge provided below. Do not guess or invent anything that isn't written there — " +
16
+ "say that the site doesn't cover that point, then guide the visitor to the Contact page. " +
17
+ "Do not invent official names or expansions of terms that aren't in the knowledge either.\n\n" +
18
+ "Do not use Markdown formatting (such as `**`, `#`, or `-` lists) — answer in plain text. " +
19
+ "If the answer runs long, insert a blank line every 2-4 sentences to break it into paragraphs.",
20
+ };
10
21
  const ROUTE_INSTRUCTIONS = {
11
- thoughts: "訪問者は作者の考え方について質問しています。知識の内容に沿って考え方を説明してください。",
12
- works: "訪問者はWorks(作品)について質問しています。知識に書かれた内容から解説し、" +
13
- "対応するZenn記事へのリンクが知識中にあれば案内してください。",
14
- inquiry: "訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページへの問い合わせを案内してください。",
22
+ ja: {
23
+ thoughts: "訪問者は作者の考え方について質問しています。知識の内容に沿って考え方を説明してください。",
24
+ works: "訪問者はWorks(作品)について質問しています。知識に書かれた内容から解説し、" +
25
+ "対応するZenn記事へのリンクが知識中にあれば案内してください。",
26
+ inquiry: "訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページへの問い合わせを案内してください。",
27
+ },
28
+ en: {
29
+ thoughts: "The visitor is asking about the author's thinking. Explain it based on the knowledge provided.",
30
+ works: "The visitor is asking about Works (projects). Explain based on the knowledge, and if a link to the " +
31
+ "corresponding Zenn article is included in the knowledge, share it.",
32
+ inquiry: "The visitor wants to discuss hiring or a project request. Respond briefly, then guide them to contact via the Contact page.",
33
+ },
34
+ };
35
+ const KNOWLEDGE_LABEL = {
36
+ ja: "---- 知識 ----",
37
+ en: "---- Knowledge ----",
15
38
  };
16
- function buildInquiryInstruction(contactUrl) {
39
+ function buildInquiryInstruction(language, contactUrl) {
17
40
  if (contactUrl) {
18
- return `訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページ(${contactUrl})への問い合わせを案内してください。`;
41
+ return language === "en"
42
+ ? `The visitor wants to discuss hiring or a project request. Respond briefly, then guide them to contact via the Contact page (${contactUrl}).`
43
+ : `訪問者は仕事の依頼・相談をしようとしています。簡潔に応じたうえで、Contactページ(${contactUrl})への問い合わせを案内してください。`;
19
44
  }
20
- return ROUTE_INSTRUCTIONS.inquiry;
45
+ return ROUTE_INSTRUCTIONS[language].inquiry;
21
46
  }
22
- export function buildSystemPrompt(knowledge, route, contactUrl) {
23
- const routeInstruction = route === "inquiry" ? buildInquiryInstruction(contactUrl) : ROUTE_INSTRUCTIONS[route];
24
- return [COMMON_PREAMBLE, routeInstruction, "---- 知識 ----", knowledge].join("\n\n");
47
+ export function buildSystemPrompt(knowledge, route, contactUrl, language = DEFAULT_LANGUAGE) {
48
+ const routeInstruction = route === "inquiry" ? buildInquiryInstruction(language, contactUrl) : ROUTE_INSTRUCTIONS[language][route];
49
+ return [COMMON_PREAMBLE[language], routeInstruction, KNOWLEDGE_LABEL[language], knowledge].join("\n\n");
25
50
  }
26
51
  export function createGeminiGenerator(config) {
27
52
  const client = new GoogleGenAI({ apiKey: config.apiKey });
28
53
  const model = config.model ?? "gemini-3.1-flash-lite";
54
+ const language = config.language ?? DEFAULT_LANGUAGE;
29
55
  return async (input, route) => {
30
56
  if (route === "rate_limited") {
31
57
  throw new Error("generateAnswer should not be called for a rate-limited route");
@@ -33,7 +59,7 @@ export function createGeminiGenerator(config) {
33
59
  const response = await client.models.generateContent({
34
60
  model,
35
61
  contents: input,
36
- config: { systemInstruction: buildSystemPrompt(config.knowledge, route, config.contactUrl) },
62
+ config: { systemInstruction: buildSystemPrompt(config.knowledge, route, config.contactUrl, language) },
37
63
  });
38
64
  return response.text ?? "";
39
65
  };
@@ -1,6 +1,18 @@
1
1
  import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
2
2
  import { classifyRoute } from "./route.js";
3
- const GENERATION_FAILED_MESSAGE = "本日の受付上限に達したか、一時的な不具合が発生しています。恐れ入りますがContactからお問い合わせください。";
3
+ import { DEFAULT_LANGUAGE } from "./types.js";
4
+ const RATE_LIMIT_REASON_TEXT = {
5
+ ja: { daily: "本日の上限", shortWindow: "直近の質問数の上限" },
6
+ en: { daily: "today's limit", shortWindow: "the recent question limit" },
7
+ };
8
+ const OVER_LIMIT_MESSAGE = {
9
+ ja: (reasonText) => `${reasonText}に達しました。お急ぎの場合はContactからお問い合わせください。`,
10
+ en: (reasonText) => `You've reached ${reasonText}. If it's urgent, please contact us via the Contact page.`,
11
+ };
12
+ const GENERATION_FAILED_MESSAGE = {
13
+ ja: "本日の受付上限に達したか、一時的な不具合が発生しています。恐れ入りますがContactからお問い合わせください。",
14
+ en: "You may have reached today's limit, or a temporary issue has occurred. Please contact us via the Contact page.",
15
+ };
4
16
  const ChatState = Annotation.Root({
5
17
  input: Annotation(),
6
18
  ip: Annotation(),
@@ -9,26 +21,27 @@ const ChatState = Annotation.Root({
9
21
  answer: Annotation(),
10
22
  });
11
23
  export function buildChatGraph(deps) {
24
+ const language = deps.language ?? DEFAULT_LANGUAGE;
12
25
  const graph = new StateGraph(ChatState)
13
26
  .addNode("input_guard", async (state) => {
14
27
  const result = await deps.checkRateLimit(state.ip);
15
28
  if (!result.allowed) {
16
- const reasonText = result.reason === "daily" ? "本日の上限" : "直近の質問数の上限";
29
+ const reasonText = result.reason === "daily" ? RATE_LIMIT_REASON_TEXT[language].daily : RATE_LIMIT_REASON_TEXT[language].shortWindow;
17
30
  return {
18
31
  overLimit: true,
19
- answer: `${reasonText}に達しました。お急ぎの場合はContactからお問い合わせください。`,
32
+ answer: OVER_LIMIT_MESSAGE[language](reasonText),
20
33
  };
21
34
  }
22
35
  return { overLimit: false };
23
36
  })
24
- .addNode("route_message", async (state) => ({ route: classifyRoute(state.input) }))
37
+ .addNode("route_message", async (state) => ({ route: classifyRoute(state.input, language) }))
25
38
  .addNode("generate", async (state) => {
26
39
  try {
27
40
  return { answer: await deps.generateAnswer(state.input, state.route) };
28
41
  }
29
42
  catch (error) {
30
43
  console.error("generateAnswer failed", error);
31
- return { answer: GENERATION_FAILED_MESSAGE };
44
+ return { answer: GENERATION_FAILED_MESSAGE[language] };
32
45
  }
33
46
  })
34
47
  .addNode("log", async (state) => {
@@ -1,8 +1,9 @@
1
1
  import type { GenerateAnswerFn } from "./gemini.js";
2
- import type { RateLimitConfig } from "./types.js";
2
+ import type { Language, RateLimitConfig } from "./types.js";
3
3
  export interface ChatHandlerConfig {
4
4
  db: D1Database;
5
5
  generateAnswer: GenerateAnswerFn;
6
6
  rateLimitConfig?: RateLimitConfig;
7
+ language?: Language;
7
8
  }
8
9
  export declare function createChatHandler(config: ChatHandlerConfig): (request: Request) => Promise<Response>;
@@ -8,6 +8,7 @@ export function createChatHandler(config) {
8
8
  checkRateLimit: (ip) => checkRateLimit(config.db, ip, new Date(), config.rateLimitConfig ?? DEFAULT_RATE_LIMIT_CONFIG),
9
9
  generateAnswer: config.generateAnswer,
10
10
  logChat: (entry) => logChat(config.db, entry),
11
+ language: config.language,
11
12
  });
12
13
  return async (request) => {
13
14
  let body;
@@ -1,2 +1,2 @@
1
- import type { ChatRoute } from "./types.js";
2
- export declare function classifyRoute(input: string): Exclude<ChatRoute, "rate_limited">;
1
+ import type { ChatRoute, Language } from "./types.js";
2
+ export declare function classifyRoute(input: string, language?: Language): Exclude<ChatRoute, "rate_limited">;
@@ -1,11 +1,20 @@
1
- const INQUIRY_KEYWORDS = ["依頼", "相談", "見積", "発注", "案件", "お願いしたい"];
2
- const WORKS_KEYWORDS = ["works", "作品", "実績", "zenn", "記事"];
3
- export function classifyRoute(input) {
1
+ import { DEFAULT_LANGUAGE } from "./types.js";
2
+ const INQUIRY_KEYWORDS = {
3
+ ja: ["依頼", "相談", "見積", "発注", "案件", "お願いしたい"],
4
+ en: ["hire", "quote", "estimate", "commission", "inquiry", "work with you", "project request"],
5
+ };
6
+ const WORKS_KEYWORDS = {
7
+ ja: ["works", "作品", "実績", "zenn", "記事"],
8
+ en: ["works", "portfolio", "article", "zenn", "project"],
9
+ };
10
+ export function classifyRoute(input, language = DEFAULT_LANGUAGE) {
4
11
  const lower = input.toLowerCase();
5
- if (INQUIRY_KEYWORDS.some((keyword) => input.includes(keyword))) {
12
+ const inquiryKeywords = INQUIRY_KEYWORDS[language];
13
+ const worksKeywords = WORKS_KEYWORDS[language];
14
+ if (inquiryKeywords.some((keyword) => lower.includes(keyword))) {
6
15
  return "inquiry";
7
16
  }
8
- if (WORKS_KEYWORDS.some((keyword) => lower.includes(keyword))) {
17
+ if (worksKeywords.some((keyword) => lower.includes(keyword))) {
9
18
  return "works";
10
19
  }
11
20
  return "thoughts";
@@ -1,4 +1,6 @@
1
1
  export type ChatRoute = "thoughts" | "works" | "inquiry" | "rate_limited";
2
+ export type Language = "ja" | "en";
3
+ export declare const DEFAULT_LANGUAGE: Language;
2
4
  export interface RateLimitConfig {
3
5
  shortWindowMinutes: number;
4
6
  shortWindowMax: number;
@@ -21,4 +23,5 @@ export interface ChatGraphDeps {
21
23
  checkRateLimit: (ip: string) => Promise<RateLimitResult>;
22
24
  generateAnswer: (input: string, route: ChatRoute) => Promise<string>;
23
25
  logChat: (entry: ChatLogEntry) => Promise<void>;
26
+ language?: Language;
24
27
  }
@@ -1,3 +1,4 @@
1
+ export const DEFAULT_LANGUAGE = "ja";
1
2
  export const DEFAULT_RATE_LIMIT_CONFIG = {
2
3
  shortWindowMinutes: 10,
3
4
  shortWindowMax: 3,
File without changes
@@ -1,3 +1,4 @@
1
+ import type { Language } from "../chat/types.js";
1
2
  export interface IngestConfig {
2
3
  /** Absolute or cwd-relative path to the built site (e.g. "dist"). */
3
4
  distDir: string;
@@ -11,6 +12,18 @@ export interface IngestConfig {
11
12
  zenn?: ZennIngestConfig;
12
13
  /** Token threshold above which the CLI warns that CAG may no longer fit. */
13
14
  tokenWarningThreshold?: number;
15
+ /** UI language captured by `folio-agent-init`. Ignored by ingest itself. */
16
+ language?: Language;
17
+ /** Widget theme colors captured by `folio-agent-init`. Ignored by ingest itself. */
18
+ theme?: ThemeColors;
19
+ }
20
+ export interface ThemeColors {
21
+ /** Hex color for `--folio-agent-accent`, e.g. "#2563eb". */
22
+ accent: string;
23
+ /** Hex color for `--folio-agent-surface`, e.g. "#ffffff". */
24
+ surface: string;
25
+ /** Hex color for `--folio-agent-text`, e.g. "#111827". */
26
+ text: string;
14
27
  }
15
28
  export interface ZennIngestConfig {
16
29
  /** Local path (absolute or cwd-relative) to the Zenn CLI articles/ directory. */
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import * as clack from "@clack/prompts";
6
+ import { DEFAULT_WIZARD_ANSWERS, runWizard } from "./questions.js";
7
+ import { appendIngestToBuildScript, buildApiRouteTemplate, buildConfigJson, buildThemeCss, deriveEndpointPath, upsertDevVar, } from "./writers.js";
8
+ const CONFIG_PATH = "folio-agent.config.json";
9
+ const THEME_CSS_PATH = "folio-agent.theme.css";
10
+ const PACKAGE_JSON_PATH = "package.json";
11
+ const DEV_VARS_PATH = ".dev.vars";
12
+ const KNOWLEDGE_OUTPUT_PATH = "dist/knowledge.json";
13
+ const CLI_TEXT = {
14
+ ja: {
15
+ summaryTitle: "以下の内容で書き込みます:",
16
+ summaryConfigLine: `${CONFIG_PATH} を生成・更新`,
17
+ summaryThemeLine: `${THEME_CSS_PATH} を生成・更新(再実行時はこのファイルの書き換えだけで dev サーバーの HMR に反映されます)`,
18
+ summaryApiRouteLine: (path) => `${path} を新規生成`,
19
+ summaryApiRouteSkippedLine: (path) => `${path} は既に存在するためスキップします`,
20
+ summaryBuildScriptLine: `${PACKAGE_JSON_PATH} の build スクリプトに ingest コマンドを追記`,
21
+ summaryBuildScriptUnchangedLine: `${PACKAGE_JSON_PATH} の build スクリプトは変更なし(既に含まれています)`,
22
+ summaryDevVarsLine: `${DEV_VARS_PATH} に GEMINI_API_KEY を書き込み`,
23
+ confirmMessage: "この内容で書き込みますか?",
24
+ writeCancelledMessage: "書き込みを中止しました。",
25
+ outroTitle: "セットアップが完了しました。",
26
+ snippetIntro: "レイアウトに以下を1回だけ貼り付けてください(再実行時は不要です):",
27
+ d1Note: "D1 が未設定でも widget の表示とテーマ確認はローカルで動きます。チャット応答には D1 のセットアップと Gemini API キーが必要です。",
28
+ },
29
+ en: {
30
+ summaryTitle: "The following will be written:",
31
+ summaryConfigLine: `Generate/update ${CONFIG_PATH}`,
32
+ summaryThemeLine: `Generate/update ${THEME_CSS_PATH} (re-running the wizard only rewrites this file, and your dev server's HMR picks it up)`,
33
+ summaryApiRouteLine: (path) => `Generate ${path}`,
34
+ summaryApiRouteSkippedLine: (path) => `Skip ${path} (already exists)`,
35
+ summaryBuildScriptLine: `Append the ingest command to ${PACKAGE_JSON_PATH}'s build script`,
36
+ summaryBuildScriptUnchangedLine: `No change to ${PACKAGE_JSON_PATH}'s build script (already present)`,
37
+ summaryDevVarsLine: `Write GEMINI_API_KEY to ${DEV_VARS_PATH}`,
38
+ confirmMessage: "Proceed with writing these files?",
39
+ writeCancelledMessage: "Write cancelled.",
40
+ outroTitle: "Setup complete.",
41
+ snippetIntro: "Paste the following into your layout once (not needed again on re-runs):",
42
+ d1Note: "Without D1 set up, the widget still renders and the theme can be checked locally. Chat responses require D1 and a Gemini API key.",
43
+ },
44
+ };
45
+ async function readJson(path) {
46
+ if (!existsSync(path))
47
+ return undefined;
48
+ try {
49
+ return JSON.parse(await readFile(path, "utf-8"));
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ async function readTextOrEmpty(path) {
56
+ return existsSync(path) ? readFile(path, "utf-8") : "";
57
+ }
58
+ function seedDefaults(previous) {
59
+ if (!previous)
60
+ return DEFAULT_WIZARD_ANSWERS;
61
+ return {
62
+ ...DEFAULT_WIZARD_ANSWERS,
63
+ language: previous.language ?? DEFAULT_WIZARD_ANSWERS.language,
64
+ distDir: previous.distDir ?? DEFAULT_WIZARD_ANSWERS.distDir,
65
+ include: previous.include ?? DEFAULT_WIZARD_ANSWERS.include,
66
+ zenn: previous.zenn,
67
+ theme: previous.theme ?? DEFAULT_WIZARD_ANSWERS.theme,
68
+ };
69
+ }
70
+ async function main() {
71
+ const previousConfig = await readJson(CONFIG_PATH);
72
+ const answers = await runWizard(seedDefaults(previousConfig));
73
+ const text = CLI_TEXT[answers.language];
74
+ const configJson = buildConfigJson(answers, previousConfig);
75
+ const themeCss = buildThemeCss(answers.theme);
76
+ const apiRouteExists = existsSync(answers.apiRoutePath);
77
+ const apiRouteContent = apiRouteExists
78
+ ? undefined
79
+ : buildApiRouteTemplate({
80
+ apiRoutePath: answers.apiRoutePath,
81
+ distDir: answers.distDir,
82
+ contactUrl: answers.contactUrl,
83
+ language: answers.language,
84
+ });
85
+ const pkg = (await readJson(PACKAGE_JSON_PATH)) ?? {};
86
+ const scripts = pkg.scripts ?? {};
87
+ const nextBuildScript = appendIngestToBuildScript(scripts.build, CONFIG_PATH, KNOWLEDGE_OUTPUT_PATH);
88
+ const buildScriptChanged = nextBuildScript !== (scripts.build ?? "");
89
+ const nextDevVars = answers.geminiApiKey
90
+ ? upsertDevVar(await readTextOrEmpty(DEV_VARS_PATH), "GEMINI_API_KEY", answers.geminiApiKey)
91
+ : undefined;
92
+ const summaryLines = [
93
+ text.summaryConfigLine,
94
+ text.summaryThemeLine,
95
+ apiRouteExists ? text.summaryApiRouteSkippedLine(answers.apiRoutePath) : text.summaryApiRouteLine(answers.apiRoutePath),
96
+ buildScriptChanged ? text.summaryBuildScriptLine : text.summaryBuildScriptUnchangedLine,
97
+ ];
98
+ if (nextDevVars !== undefined) {
99
+ summaryLines.push(text.summaryDevVarsLine);
100
+ }
101
+ clack.note(summaryLines.join("\n"), text.summaryTitle);
102
+ const shouldWrite = await clack.confirm({ message: text.confirmMessage, initialValue: true });
103
+ if (clack.isCancel(shouldWrite) || !shouldWrite) {
104
+ clack.cancel(text.writeCancelledMessage);
105
+ return;
106
+ }
107
+ await writeFile(CONFIG_PATH, `${JSON.stringify(configJson, null, 2)}\n`);
108
+ await writeFile(THEME_CSS_PATH, themeCss);
109
+ if (apiRouteContent) {
110
+ await mkdir(dirname(answers.apiRoutePath), { recursive: true });
111
+ await writeFile(answers.apiRoutePath, apiRouteContent);
112
+ }
113
+ if (buildScriptChanged) {
114
+ const nextPkg = { ...pkg, scripts: { ...scripts, build: nextBuildScript } };
115
+ await writeFile(PACKAGE_JSON_PATH, `${JSON.stringify(nextPkg, null, 2)}\n`);
116
+ }
117
+ if (nextDevVars !== undefined) {
118
+ await writeFile(DEV_VARS_PATH, nextDevVars);
119
+ }
120
+ const endpoint = deriveEndpointPath(answers.apiRoutePath);
121
+ const snippet = [
122
+ `<link rel="stylesheet" href="/${THEME_CSS_PATH}">`,
123
+ `<folio-agent-widget endpoint="${endpoint}" policy-href="/data-policy" lang="${answers.language}"></folio-agent-widget>`,
124
+ '<script type="module">',
125
+ ' import { defineFolioAgentWidget } from "@folio-agent/widget";',
126
+ " defineFolioAgentWidget();",
127
+ "</script>",
128
+ ].join("\n");
129
+ clack.note(snippet, text.snippetIntro);
130
+ clack.note(text.d1Note);
131
+ clack.outro(text.outroTitle);
132
+ }
133
+ main().catch((error) => {
134
+ console.error(error);
135
+ process.exitCode = 1;
136
+ });
@@ -0,0 +1,20 @@
1
+ import type { Language } from "../chat/types.js";
2
+ import type { ThemeColors, ZennIngestConfig } from "../ingest/types.js";
3
+ export interface WizardAnswers {
4
+ language: Language;
5
+ distDir: string;
6
+ include: string[];
7
+ zenn?: ZennIngestConfig;
8
+ contactUrl?: string;
9
+ theme: ThemeColors;
10
+ /** Non-empty only when the visitor typed a new key this run; blank means "leave .dev.vars untouched". */
11
+ geminiApiKey?: string;
12
+ apiRoutePath: string;
13
+ }
14
+ export declare const DEFAULT_WIZARD_ANSWERS: WizardAnswers;
15
+ /** Pure hex color validation used both as a `clack.text` validator and in tests. */
16
+ export declare function isValidHexColor(value: string): boolean;
17
+ /** Splits a comma-separated glob list into a trimmed, non-empty array. */
18
+ export declare function parseIncludeList(value: string): string[];
19
+ /** Runs the interactive wizard (all-defaults-Enter-to-quick-mode) and returns the collected answers. */
20
+ export declare function runWizard(defaults: WizardAnswers): Promise<WizardAnswers>;
@@ -0,0 +1,125 @@
1
+ import * as clack from "@clack/prompts";
2
+ import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
+ export const DEFAULT_WIZARD_ANSWERS = {
4
+ language: DEFAULT_LANGUAGE,
5
+ distDir: "dist",
6
+ include: ["/**"],
7
+ theme: { accent: "#2563eb", surface: "#ffffff", text: "#111827" },
8
+ apiRoutePath: "functions/api/chat.ts",
9
+ };
10
+ const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
11
+ /** Pure hex color validation used both as a `clack.text` validator and in tests. */
12
+ export function isValidHexColor(value) {
13
+ return HEX_COLOR_PATTERN.test(value.trim());
14
+ }
15
+ /** Splits a comma-separated glob list into a trimmed, non-empty array. */
16
+ export function parseIncludeList(value) {
17
+ return value
18
+ .split(",")
19
+ .map((item) => item.trim())
20
+ .filter((item) => item.length > 0);
21
+ }
22
+ const WIZARD_TEXT = {
23
+ ja: {
24
+ distDirMessage: "ビルド成果物のディレクトリ(distDir)",
25
+ includeMessage: "知識に含める URL パスのグロブ(カンマ区切りで複数可)",
26
+ includePlaceholder: "/**",
27
+ zennConfirmMessage: "Zenn 記事も知識に含めますか?",
28
+ zennArticlesDirMessage: "Zenn CLI の articles/ ディレクトリへのパス",
29
+ zennBaseUrlMessage: "記事の公開 URL のベース(例: https://zenn.dev/<username>/articles)",
30
+ contactUrlMessage: "Contact ページの URL(任意。空 Enter でスキップ)",
31
+ contactUrlPlaceholder: "https://example.com/contact",
32
+ accentMessage: "テーマカラー: accent(#付き hex)",
33
+ surfaceMessage: "テーマカラー: surface(#付き hex)",
34
+ textColorMessage: "テーマカラー: text(#付き hex)",
35
+ colorValidationError: "#fff や #ffffff のような hex カラーコードで入力してください。",
36
+ geminiApiKeyMessage: "Gemini API キー(任意。空 Enter でスキップ)",
37
+ geminiApiKeySkipNote: "スキップしました。キーは https://aistudio.google.com/apikey から取得できます。",
38
+ apiRoutePathMessage: "API ルート雛形の出力先",
39
+ cancelledMessage: "セットアップを中止しました。",
40
+ },
41
+ en: {
42
+ distDirMessage: "Build output directory (distDir)",
43
+ includeMessage: "URL path globs to include in the knowledge (comma-separated for multiple)",
44
+ includePlaceholder: "/**",
45
+ zennConfirmMessage: "Include Zenn articles in the knowledge too?",
46
+ zennArticlesDirMessage: "Path to the Zenn CLI articles/ directory",
47
+ zennBaseUrlMessage: "Base URL for published articles (e.g. https://zenn.dev/<username>/articles)",
48
+ contactUrlMessage: "Contact page URL (optional, press Enter to skip)",
49
+ contactUrlPlaceholder: "https://example.com/contact",
50
+ accentMessage: "Theme color: accent (hex with #)",
51
+ surfaceMessage: "Theme color: surface (hex with #)",
52
+ textColorMessage: "Theme color: text (hex with #)",
53
+ colorValidationError: "Enter a hex color code like #fff or #ffffff.",
54
+ geminiApiKeyMessage: "Gemini API key (optional, press Enter to skip)",
55
+ geminiApiKeySkipNote: "Skipped. Get a key at https://aistudio.google.com/apikey",
56
+ apiRoutePathMessage: "Output path for the API route scaffold",
57
+ cancelledMessage: "Setup cancelled.",
58
+ },
59
+ };
60
+ function unwrap(value, cancelledMessage) {
61
+ if (clack.isCancel(value)) {
62
+ clack.cancel(cancelledMessage);
63
+ process.exit(0);
64
+ }
65
+ return value;
66
+ }
67
+ /** Runs the interactive wizard (all-defaults-Enter-to-quick-mode) and returns the collected answers. */
68
+ export async function runWizard(defaults) {
69
+ clack.intro("folio-agent-init");
70
+ const language = unwrap(await clack.select({
71
+ message: "Language / 言語",
72
+ options: [
73
+ { value: "ja", label: "日本語" },
74
+ { value: "en", label: "English" },
75
+ ],
76
+ initialValue: defaults.language,
77
+ }), WIZARD_TEXT[defaults.language].cancelledMessage);
78
+ const text = WIZARD_TEXT[language];
79
+ const distDir = unwrap(await clack.text({ message: text.distDirMessage, initialValue: defaults.distDir }), text.cancelledMessage);
80
+ const includeInput = unwrap(await clack.text({
81
+ message: text.includeMessage,
82
+ placeholder: text.includePlaceholder,
83
+ initialValue: defaults.include.join(", "),
84
+ }), text.cancelledMessage);
85
+ const include = parseIncludeList(includeInput);
86
+ const wantsZenn = unwrap(await clack.confirm({ message: text.zennConfirmMessage, initialValue: Boolean(defaults.zenn) }), text.cancelledMessage);
87
+ let zenn;
88
+ if (wantsZenn) {
89
+ const articlesDir = unwrap(await clack.text({
90
+ message: text.zennArticlesDirMessage,
91
+ initialValue: defaults.zenn?.articlesDir ?? "../zenn-content/articles",
92
+ }), text.cancelledMessage);
93
+ const baseUrl = unwrap(await clack.text({
94
+ message: text.zennBaseUrlMessage,
95
+ initialValue: defaults.zenn?.baseUrl ?? "",
96
+ }), text.cancelledMessage);
97
+ zenn = { articlesDir, baseUrl };
98
+ }
99
+ const contactUrlInput = unwrap(await clack.text({
100
+ message: text.contactUrlMessage,
101
+ placeholder: text.contactUrlPlaceholder,
102
+ initialValue: defaults.contactUrl ?? "",
103
+ }), text.cancelledMessage);
104
+ const contactUrl = contactUrlInput.trim().length > 0 ? contactUrlInput.trim() : undefined;
105
+ const colorValidate = (value) => isValidHexColor(value ?? "") ? undefined : text.colorValidationError;
106
+ const accent = unwrap(await clack.text({ message: text.accentMessage, initialValue: defaults.theme.accent, validate: colorValidate }), text.cancelledMessage);
107
+ const surface = unwrap(await clack.text({ message: text.surfaceMessage, initialValue: defaults.theme.surface, validate: colorValidate }), text.cancelledMessage);
108
+ const textColor = unwrap(await clack.text({ message: text.textColorMessage, initialValue: defaults.theme.text, validate: colorValidate }), text.cancelledMessage);
109
+ const geminiApiKeyInput = unwrap(await clack.password({ message: text.geminiApiKeyMessage }), text.cancelledMessage);
110
+ const geminiApiKey = geminiApiKeyInput.trim().length > 0 ? geminiApiKeyInput.trim() : undefined;
111
+ if (!geminiApiKey) {
112
+ clack.note(text.geminiApiKeySkipNote);
113
+ }
114
+ const apiRoutePath = unwrap(await clack.text({ message: text.apiRoutePathMessage, initialValue: defaults.apiRoutePath }), text.cancelledMessage);
115
+ return {
116
+ language,
117
+ distDir,
118
+ include,
119
+ zenn,
120
+ contactUrl,
121
+ theme: { accent, surface, text: textColor },
122
+ geminiApiKey,
123
+ apiRoutePath,
124
+ };
125
+ }
@@ -0,0 +1,26 @@
1
+ import type { Language } from "../chat/types.js";
2
+ import type { IngestConfig, ThemeColors } from "../ingest/types.js";
3
+ import type { WizardAnswers } from "./questions.js";
4
+ /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
5
+ export declare function buildConfigJson(answers: WizardAnswers, previous?: IngestConfig): IngestConfig;
6
+ /** Builds `folio-agent.theme.css` content. Re-running the wizard only rewrites this file, which HMR picks up. */
7
+ export declare function buildThemeCss(theme: ThemeColors): string;
8
+ /**
9
+ * Computes the relative import specifier from the API route file to `<distDir>/knowledge.json`,
10
+ * independent of the OS path separator (config values are always POSIX-style project-relative paths).
11
+ */
12
+ export declare function buildKnowledgeImportPath(apiRoutePath: string, distDir: string): string;
13
+ /** Derives the widget `endpoint` URL path from a Pages Functions file path, e.g. "functions/api/chat.ts" -> "/api/chat". */
14
+ export declare function deriveEndpointPath(apiRoutePath: string): string;
15
+ export interface ApiRouteAnswers {
16
+ apiRoutePath: string;
17
+ distDir: string;
18
+ contactUrl?: string;
19
+ language: Language;
20
+ }
21
+ /** Builds a minimal Cloudflare Pages Function that wires up `createChatHandler`. */
22
+ export declare function buildApiRouteTemplate(answers: ApiRouteAnswers): string;
23
+ /** Appends the ingest command to a `build` script if it isn't already present; otherwise returns it unchanged. */
24
+ export declare function appendIngestToBuildScript(buildScript: string | undefined, configPath: string, knowledgeOutputPath: string): string;
25
+ /** Adds or replaces a single `KEY=value` line in `.dev.vars` content, leaving other lines untouched. */
26
+ export declare function upsertDevVar(content: string, key: string, value: string): string;
@@ -0,0 +1,105 @@
1
+ import { posix } from "node:path";
2
+ import { DEFAULT_LANGUAGE } from "../chat/types.js";
3
+ const INGEST_COMMAND_PREFIX = "folio-agent-ingest";
4
+ /** Builds `folio-agent.config.json` content, preserving fields the wizard never asks about. */
5
+ export function buildConfigJson(answers, previous) {
6
+ const config = {
7
+ distDir: answers.distDir,
8
+ include: answers.include,
9
+ language: answers.language,
10
+ theme: answers.theme,
11
+ };
12
+ if (previous?.exclude) {
13
+ config.exclude = previous.exclude;
14
+ }
15
+ if (previous?.knowledgeDir) {
16
+ config.knowledgeDir = previous.knowledgeDir;
17
+ }
18
+ if (previous?.tokenWarningThreshold !== undefined) {
19
+ config.tokenWarningThreshold = previous.tokenWarningThreshold;
20
+ }
21
+ if (answers.zenn) {
22
+ config.zenn = answers.zenn;
23
+ }
24
+ return config;
25
+ }
26
+ /** Builds `folio-agent.theme.css` content. Re-running the wizard only rewrites this file, which HMR picks up. */
27
+ export function buildThemeCss(theme) {
28
+ return [
29
+ "folio-agent-widget {",
30
+ ` --folio-agent-accent: ${theme.accent};`,
31
+ ` --folio-agent-surface: ${theme.surface};`,
32
+ ` --folio-agent-text: ${theme.text};`,
33
+ "}",
34
+ "",
35
+ ].join("\n");
36
+ }
37
+ /**
38
+ * Computes the relative import specifier from the API route file to `<distDir>/knowledge.json`,
39
+ * independent of the OS path separator (config values are always POSIX-style project-relative paths).
40
+ */
41
+ export function buildKnowledgeImportPath(apiRoutePath, distDir) {
42
+ const fromDir = posix.dirname(apiRoutePath);
43
+ const target = posix.join(distDir, "knowledge.json");
44
+ const relative = posix.relative(fromDir, target);
45
+ return relative.startsWith(".") ? relative : `./${relative}`;
46
+ }
47
+ /** Derives the widget `endpoint` URL path from a Pages Functions file path, e.g. "functions/api/chat.ts" -> "/api/chat". */
48
+ export function deriveEndpointPath(apiRoutePath) {
49
+ const withoutExt = apiRoutePath.replace(/\.tsx?$/, "");
50
+ const withoutFunctionsPrefix = withoutExt.replace(/^functions\//, "");
51
+ return `/${withoutFunctionsPrefix}`;
52
+ }
53
+ /** Builds a minimal Cloudflare Pages Function that wires up `createChatHandler`. */
54
+ export function buildApiRouteTemplate(answers) {
55
+ const knowledgeImportPath = buildKnowledgeImportPath(answers.apiRoutePath, answers.distDir);
56
+ const languageLine = answers.language === DEFAULT_LANGUAGE ? "" : `\n language: ${JSON.stringify(answers.language)},`;
57
+ const contactUrlLine = answers.contactUrl ? `\n contactUrl: ${JSON.stringify(answers.contactUrl)},` : "";
58
+ return [
59
+ 'import { createChatHandler, createGeminiGenerator } from "@folio-agent/handler";',
60
+ `import knowledgeDoc from "${knowledgeImportPath}";`,
61
+ "",
62
+ 'const knowledge = knowledgeDoc.pages.map((page) => `# ${page.url}\\n\\n${page.text}`).join("\\n\\n");',
63
+ "",
64
+ "interface Env {",
65
+ " DB: D1Database;",
66
+ " GEMINI_API_KEY: string;",
67
+ "}",
68
+ "",
69
+ "export const onRequestPost: PagesFunction<Env> = async (context) => {",
70
+ " const handle = createChatHandler({",
71
+ ` db: context.env.DB,${languageLine}`,
72
+ " generateAnswer: createGeminiGenerator({",
73
+ " apiKey: context.env.GEMINI_API_KEY,",
74
+ ` knowledge,${contactUrlLine}`,
75
+ " }),",
76
+ " });",
77
+ " return handle(context.request);",
78
+ "};",
79
+ "",
80
+ ].join("\n");
81
+ }
82
+ /** Appends the ingest command to a `build` script if it isn't already present; otherwise returns it unchanged. */
83
+ export function appendIngestToBuildScript(buildScript, configPath, knowledgeOutputPath) {
84
+ const ingestCommand = `${INGEST_COMMAND_PREFIX} ${configPath} ${knowledgeOutputPath}`;
85
+ const base = (buildScript ?? "").trim();
86
+ if (base.includes(ingestCommand)) {
87
+ return base;
88
+ }
89
+ return base.length > 0 ? `${base} && ${ingestCommand}` : ingestCommand;
90
+ }
91
+ /** Adds or replaces a single `KEY=value` line in `.dev.vars` content, leaving other lines untouched. */
92
+ export function upsertDevVar(content, key, value) {
93
+ const trimmed = content.replace(/\n+$/, "");
94
+ const lines = trimmed.length > 0 ? trimmed.split("\n") : [];
95
+ const prefix = `${key}=`;
96
+ const newLine = `${prefix}${value}`;
97
+ const index = lines.findIndex((line) => line.startsWith(prefix));
98
+ if (index >= 0) {
99
+ lines[index] = newLine;
100
+ }
101
+ else {
102
+ lines.push(newLine);
103
+ }
104
+ return `${lines.join("\n")}\n`;
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/handler",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Chat handler (LangGraph.js on Cloudflare Workers) + knowledge ingestion CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,8 @@
11
11
  "migrations"
12
12
  ],
13
13
  "bin": {
14
- "folio-agent-ingest": "dist/ingest/cli.js"
14
+ "folio-agent-ingest": "dist/ingest/cli.js",
15
+ "folio-agent-init": "dist/init/cli.js"
15
16
  },
16
17
  "repository": {
17
18
  "type": "git",
@@ -37,6 +38,7 @@
37
38
  "prepublishOnly": "tsc -b --force"
38
39
  },
39
40
  "dependencies": {
41
+ "@clack/prompts": "^1.7.0",
40
42
  "@google/genai": "^2.10.0",
41
43
  "@langchain/langgraph": "^1.4.7",
42
44
  "node-html-parser": "^8.0.4",