PageSourceSearch

https://coffee-labo.co.jp/wp-content/plugins/coffee-labo-webmcp/assets/js/coffee-labo-webmcp.js?ver=1788698436

js coffee-labo.co.jp collected 2026-10-02 10:35:56 UTC 14,997 bytes, 405 lines download raw bytes

1(() => {
2  'use strict';
3
4  const config = window.CoffeeLaboWebMCPConfig || {};
5  const TOOL_NAMES = [
6    'search_coffee_articles',
7    'get_coffee_article',
8    'browse_coffee_topics',
9    'answer_coffee_question',
10  ];
11  const OUTPUT_BUDGET = 1450;
12  const state = {
13    initialized: false,
14    supported: false,
15    registered: [],
16    error: null,
17  };
18  let initializationPromise = null;
19
20  const clampText = (value, max = OUTPUT_BUDGET) => {
21    const text = String(value ?? '').trim();
22    return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1)).trimEnd()}…`;
23  };
24
25  const normalizeInteger = (value, fallback, min, max) => {
26    const parsed = Number.parseInt(value, 10);
27    if (!Number.isFinite(parsed)) return fallback;
28    return Math.max(min, Math.min(max, parsed));
29  };
30
31  const pushMetric = (toolName, status, resultCount = 0) => {
32    if (!config.analytics) return;
33    const analytics = window.__clwmcpAnalyticsRuntime;
34    if (!analytics || typeof analytics.send !== 'function') return;
35    analytics.send('webmcp_tool_call', {
36      webmcp_tool_name: toolName,
37      webmcp_status: status,
38      webmcp_result_count: Number(resultCount) || 0,
39    });
40  };
41
42  const fetchWithTimeout = async (url, options = {}, timeoutMs = 38000) => {
43    const controller = new AbortController();
44    const timer = window.setTimeout(() => controller.abort(), timeoutMs);
45    try {
46      return await fetch(url, { ...options, signal: controller.signal });
47    } catch (error) {
48      if (error?.name === 'AbortError') {
49        throw new Error('Request timed out.');
50      }
51      throw error;
52    } finally {
53      window.clearTimeout(timer);
54    }
55  };
56
57  const apiRequest = async (path, params = {}) => {
58    const base = String(config.restBase || '');
59    if (!base) throw new Error('WebMCP REST endpoint is not configured.');
60
61    const url = new URL(path.replace(/^\//, ''), base);
62    Object.entries(params).forEach(([key, value]) => {
63      if (value !== undefined && value !== null && value !== '') {
64        url.searchParams.set(key, String(value));
65      }
66    });
67
68    const response = await fetchWithTimeout(url.toString(), {
69      method: 'GET',
70      credentials: 'same-origin',
71      headers: { Accept: 'application/json' },
72    }, 15000);
73
74    let payload = null;
75    try {
76      payload = await response.json();
77    } catch (_) {
78      // The status text below remains useful if a proxy returns non-JSON.
79    }
80
81    if (!response.ok) {
82      throw new Error(payload?.message || `Request failed (${response.status}).`);
83    }
84    return payload;
85  };
86
87
88  const apiPost = async (urlValue, body = {}) => {
89    const url = String(urlValue || '');
90    if (!url) throw new Error('AI answer endpoint is not configured.');
91
92    const response = await fetchWithTimeout(url, {
93      method: 'POST',
94      credentials: 'same-origin',
95      headers: {
96        Accept: 'application/json',
97        'Content-Type': 'application/json',
98      },
99      body: JSON.stringify(body),
100    }, 42000);
101
102    let payload = null;
103    try {
104      payload = await response.json();
105    } catch (_) {
106      // The status text below remains useful if a proxy returns non-JSON.
107    }
108
109    if (!response.ok) {
110      throw new Error(payload?.message || `Request failed (${response.status}).`);
111    }
112    return payload;
113  };
114
115  const formatSearch = (data) => {
116    const items = Array.isArray(data?.items) ? data.items : [];
117    if (!items.length) {
118      return `「${data?.query || ''}」に一致する公開記事は見つかりませんでした。別の語句またはbrowse_coffee_topicsのテーマで再検索してください。`;
119    }
120
121    const lines = [`検索結果: 全${data.total_found ?? items.length}件中${items.length}件`];
122    items.forEach((item, index) => {
123      const topics = Array.isArray(item.categories)
124        ? item.categories.map((term) => term.name).filter(Boolean).slice(0, 3).join('、')
125        : '';
126      lines.push(
127        `${index + 1}. [ID ${item.id}] ${item.title}`,
128        `URL: ${item.url}`,
129        topics ? `テーマ: ${topics}` : '',
130        item.summary ? `要約: ${item.summary}` : '',
131        item.modified ? `更新: ${item.modified}` : ''
132      );
133    });
134    return clampText(lines.filter(Boolean).join('\n'));
135  };
136
137  const formatArticle = (data) => {
138    const topics = Array.isArray(data?.categories)
139      ? data.categories.map((term) => term.name).filter(Boolean).join('、')
140      : '';
141    return clampText([
142      data?.title || '記事',
143      data?.url ? `URL: ${data.url}` : '',
144      data?.author ? `著者: ${data.author}` : '',
145      topics ? `テーマ: ${topics}` : '',
146      data?.modified ? `更新: ${data.modified}` : '',
147      data?.content ? `本文抜粋: ${data.content}` : (data?.excerpt ? `要約: ${data.excerpt}` : ''),
148      data?.truncated ? '注: 本文はWebMCPの出力上限に合わせて抜粋しています。' : '',
149    ].filter(Boolean).join('\n'));
150  };
151
152  const formatTopics = (data) => {
153    const items = Array.isArray(data?.items) ? data.items : [];
154    if (!items.length) return '一致する記事テーマは見つかりませんでした。';
155    return clampText([
156      `記事テーマ ${items.length}件:`,
157      ...items.map((item) => `- ${item.name}(${item.count}記事 / slug: ${item.slug}) ${item.url}`),
158    ].join('\n'));
159  };
160
161
162  const formatAnswer = (data) => {
163    const sources = Array.isArray(data?.sources) ? data.sources : [];
164    const lines = [data?.answer || '回答を取得できませんでした。'];
165    if (data?.response_mode === 'free_fallback') lines.push('注: AI回答の代わりに記事本文の関連箇所を表示しています。');
166    if (data?.safety_note) lines.push(`注意: ${data.safety_note}`);
167    if (sources.length) {
168      lines.push('参考記事:');
169      sources.forEach((source, index) => {
170        lines.push(`${index + 1}. ${source.title} ${source.url}`);
171      });
172    }
173    if (data?.shop_cta?.url) {
174      lines.push(`${data.shop_cta.label || 'ラボカフェを見る'}: ${data.shop_cta.url}`);
175    }
176    return clampText(lines.join('\n'));
177  };
178
179  const tools = [
180    {
181      name: 'search_coffee_articles',
182      description: 'コーヒー豆研究所の公開記事をキーワードで検索します。コーヒー豆、器具、淹れ方、カフェ、健康、スターバックスなどの解説記事を探すときに使います。',
183      inputSchema: {
184        type: 'object',
185        additionalProperties: false,
186        properties: {
187          query: {
188            type: 'string',
189            minLength: 2,
190            maxLength: 100,
191            description: '利用者が知りたい内容を自然な日本語で指定します。',
192          },
193          topic: {
194            type: 'string',
195            description: '任意。browse_coffee_topicsで得たテーマ名またはslugです。',
196          },
197          sort: {
198            type: 'string',
199            enum: ['relevance', 'newest', 'updated'],
200            description: '関連度、新着、更新日のいずれか。既定は関連度です。',
201          },
202          limit: {
203            type: 'integer',
204            minimum: 1,
205            maximum: 5,
206            description: '返す記事数。1〜5件です。',
207          },
208        },
209        required: ['query'],
210      },
211      execute: async ({ query, topic = '', sort = 'relevance', limit = config.maxResults || 5 }) => {
212        const toolName = 'search_coffee_articles';
213        try {
214          const data = await apiRequest('search', {
215            q: String(query || '').trim(),
216            topic: String(topic || '').trim(),
217            sort,
218            limit: normalizeInteger(limit, config.maxResults || 5, 1, 5),
219          });
220          pushMetric(toolName, 'success', data?.returned || 0);
221          return formatSearch(data);
222        } catch (error) {
223          pushMetric(toolName, 'error', 0);
224          throw new Error(`記事検索に失敗しました: ${error.message}`);
225        }
226      },
227      annotations: { readOnlyHint: true, untrustedContentHint: true },
228    },
229    {
230      name: 'get_coffee_article',
231      description: 'コーヒー豆研究所の公開記事1件から、タイトル、URL、著者、更新日、カテゴリ、本文抜粋を取得します。検索結果を詳しく確認するときに使います。',
232      inputSchema: {
233        type: 'object',
234        additionalProperties: false,
235        properties: {
236          article: {
237            type: 'string',
238            maxLength: 500,
239            description: '記事ID、記事slug、またはcoffee-labo.co.jpの記事URLです。',
240          },
241          max_chars: {
242            type: 'integer',
243            minimum: 500,
244            maximum: 1200,
245            description: '本文抜粋の最大文字数。既定はサイト設定値です。',
246          },
247        },
248        required: ['article'],
249      },
250      execute: async ({ article, max_chars = config.maxArticleChars || 1000 }) => {
251        const toolName = 'get_coffee_article';
252        try {
253          const data = await apiRequest('article', {
254            article: String(article || '').trim(),
255            max_chars: normalizeInteger(max_chars, config.maxArticleChars || 1000, 500, 1200),
256          });
257          pushMetric(toolName, 'success', 1);
258          return formatArticle(data);
259        } catch (error) {
260          pushMetric(toolName, 'error', 0);
261          throw new Error(`記事取得に失敗しました: ${error.message}`);
262        }
263      },
264      annotations: { readOnlyHint: true, untrustedContentHint: true },
265    },
266    {
267      name: 'browse_coffee_topics',
268      description: 'コーヒー豆研究所の記事カテゴリを、記事数、slug、カテゴリURLとともに表示します。検索対象のテーマを決めるときに使います。',
269      inputSchema: {
270        type: 'object',
271        additionalProperties: false,
272        properties: {
273          query: {
274            type: 'string',
275            maxLength: 50,
276            description: '任意。カテゴリ名を絞る語句です。例: 器具、スタバ、淹れ方。',
277          },
278          limit: {
279            type: 'integer',
280            minimum: 1,
281            maximum: 12,
282            description: '返すテーマ数。1〜12件です。',
283          },
284        },
285      },
286      execute: async ({ query = '', limit = 12 } = {}) => {
287        const toolName = 'browse_coffee_topics';
288        try {
289          const data = await apiRequest('topics', {
290            q: String(query || '').trim(),
291            limit: normalizeInteger(limit, 12, 1, 12),
292          });
293          pushMetric(toolName, 'success', data?.returned || 0);
294          return formatTopics(data);
295        } catch (error) {
296          pushMetric(toolName, 'error', 0);
297          throw new Error(`テーマ取得に失敗しました: ${error.message}`);
298        }
299      },
300      annotations: { readOnlyHint: true, untrustedContentHint: false },
301    },
302    {
303      name: 'answer_coffee_question',
304      description: config.aiWebMcpMode === 'openai'
305        ? 'コーヒー豆研究所の公開記事だけを根拠に、コーヒーに関する質問へ短く回答し、参考記事を提示します。現在の記事がある場合はその内容を優先します。'
306        : 'コーヒー豆研究所の公開記事から質問に関連する本文箇所と参考記事を提示します。外部の生成AIは使用しません。',
307      inputSchema: {
308        type: 'object',
309        additionalProperties: false,
310        properties: {
311          question: {
312            type: 'string',
313            minLength: 6,
314            maxLength: 300,
315            description: '利用者のコーヒーに関する質問です。',
316          },
317        },
318        required: ['question'],
319      },
320      execute: async ({ question }) => {
321        const toolName = 'answer_coffee_question';
322        if (!config.aiEnabled) throw new Error('サイト内の質問・検索機能は現在無効です。');
323        try {
324          const data = await apiPost(config.aiRestUrl, {
325            question: String(question || '').trim(),
326            post_id: Number(config.aiPostId || 0),
327            token: String(config.aiToken || ''),
328            channel: 'webmcp',
329          });
330          pushMetric(toolName, 'success', Array.isArray(data?.sources) ? data.sources.length : 0);
331          return formatAnswer(data);
332        } catch (error) {
333          pushMetric(toolName, 'error', 0);
334          throw new Error(`記事からの回答取得に失敗しました: ${error.message}`);
335        }
336      },
337      annotations: { readOnlyHint: true, untrustedContentHint: true },
338    },
339  ];
340
341  const activeTools = config.aiEnabled
342    ? tools
343    : tools.filter((tool) => tool.name !== 'answer_coffee_question');
344  const activeToolNames = activeTools.map((tool) => tool.name);
345
346  const waitForModelContext = async () => {
347    for (let attempt = 0; attempt < 20; attempt += 1) {
348      if (document.modelContext?.registerTool) return document.modelContext;
349      await new Promise((resolve) => window.setTimeout(resolve, 250));
350    }
351    return null;
352  };
353
354  const initialize = () => {
355    if (initializationPromise) return initializationPromise;
356
357    initializationPromise = (async () => {
358      state.initialized = true;
359      try {
360        const modelContext = await waitForModelContext();
361        if (!modelContext) {
362          state.supported = false;
363          return state;
364        }
365
366        state.supported = true;
367        for (const tool of activeTools) {
368          await modelContext.registerTool(tool);
369          state.registered.push(tool.name);
370        }
371      } catch (error) {
372        state.error = error instanceof Error ? error.message : String(error);
373        console.error('[Coffee Labo WebMCP]', error);
374      }
375      return state;
376    })();
377
378    return initializationPromise;
379  };
380
381  window.CoffeeLaboWebMCP = Object.freeze({
382    version: config.version || '0.3.9',
383    toolNames: [...activeToolNames],
384    initialize,
385    getStatus: async () => {
386      await initialize();
387      const available = document.modelContext?.getTools
388        ? (await document.modelContext.getTools()).filter((tool) => activeToolNames.includes(tool.name)).map((tool) => tool.name)
389        : [];
390      return { ...state, available };
391    },
392    execute: async (name, args = {}) => {
393      await initialize();
394      if (!document.modelContext?.getTools || !document.modelContext?.executeTool) {
395        throw new Error('このブラウザではWebMCPを実行できません。');
396      }
397      const available = await document.modelContext.getTools();
398      const tool = available.find((candidate) => candidate.name === name && activeToolNames.includes(candidate.name));
399      if (!tool) throw new Error(`WebMCP tool not found: ${name}`);
400      return document.modelContext.executeTool(tool, JSON.stringify(args));
401    },
402  });
403
404  void initialize();
405})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.