升級版-長知柿:

透過 Google Apps Script、HTML 與 OpenAI API 的串接,我們成功打造一個 ChatGPT 問答系統,不但能讓使用者與 AI 對話,還能把資料儲存在 Google 試算表中,方便查閱與後續分析。這樣的整合方式,既強大又具彈性。

第 20 章:ChatGPT 整合 Google 試算表

在本章中,我們將學習如何將 OpenAI 的 ChatGPT 模型整合至 Google 試算表,讓使用者可以透過網頁介面與 ChatGPT 對話,並將每一筆提問與回應自動儲存至 Google Sheet。這種整合方式非常適合應用於客服、自動問答紀錄、AI 辅助筆記等情境。

程式分享如下:

code.gs

// 處理使用者訪問網頁時回傳的 HTML 頁面
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index').setTitle('ChatGPT 整合介面');
}

// 取得 ChatGPT 回應並儲存至工作表
function getChatGPTResponse(query) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('紀錄');
  if (!sheet) {
    Logger.log('找不到名為 "紀錄" 的工作表。');
    return '錯誤:找不到名為 "紀錄" 的工作表。';
  }

  // 從 E2 儲存格取得 OpenAI API 金鑰
  const apiKey = sheet.getRange('E2').getValue();
  if (!apiKey) {
    Logger.log('在 E2 儲存格找不到 API 金鑰。');
    return '錯誤:找不到 API 金鑰(應位於 E2)。';
  }

  const url = 'https://api.openai.com/v1/chat/completions';

  // 取得所有過去的回覆(C 欄),過濾掉空值
  const priorResponses = sheet.getRange('C:C').getValues().flat().filter(String);

  // 建立 ChatGPT 對話歷史
  const messages = [{ role: 'system', content: '你是一個樂於助人的助理。' }];
  priorResponses.forEach((response) => {
    messages.push({ role: 'assistant', content: response });
  });
  messages.push({ role: 'user', content: query });

  const payload = {
    model: 'gpt-4-1106-preview', // 可更換模型版本
    messages: messages,
    max_tokens: 1000,
    temperature: 0.7,
  };

  const options = {
    method: 'post',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };

  try {
    // 向 OpenAI 發送請求
    const response = UrlFetchApp.fetch(url, options);
    const responseCode = response.getResponseCode();
    const content = response.getContentText();
    Logger.log('回應碼: ' + responseCode);
    Logger.log('回應內容: ' + content);

    if (responseCode === 200) {
      const json = JSON.parse(content);
      const result = json.choices[0].message.content.trim();

      const timestamp = new Date();
      sheet.appendRow([timestamp, query, result]); // 儲存時間、問題與回應

      return result;
    } else {
      Logger.log(`錯誤:狀態碼 ${responseCode}。內容:${content}`);
      return `錯誤:收到狀態碼 ${responseCode},詳情請查看日誌。`;
    }
  } catch (error) {
    Logger.log('錯誤:' + error.toString());
    return '錯誤:無法取得回應。';
  }
}

// 取得所有回覆(C 欄)
function getAllResponses() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('紀錄');
  if (!sheet) {
    Logger.log('找不到名為 "紀錄" 的工作表。');
    return [];
  }

  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return [];

  const data = sheet.getRange(2, 3, lastRow - 1, 1).getValues(); // 從第 2 列起抓取 C 欄
  return data.flat().filter(String);
}

// 取得所有問答對(B, C 欄)
function getAllEntries() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('紀錄');
  if (!sheet) {
    Logger.log('找不到名為 "紀錄" 的工作表。');
    return [];
  }

  const lastRow = sheet.getLastRow();
  if (lastRow < 2) {
    Logger.log('紀錄表中沒有資料。');
    return [];
  }

  const queries = sheet.getRange(2, 2, lastRow - 1, 1).getValues().flat(); // 問題(B欄)
  const responses = sheet.getRange(2, 3, lastRow - 1, 1).getValues().flat(); // 回答(C欄)

  Logger.log('取得的問題:' + JSON.stringify(queries));
  Logger.log('取得的回應:' + JSON.stringify(responses));

  return queries.map((query, index) => [query, responses[index]]);
}

// 清空「紀錄」表的所有結果資料(A~C欄)
function clearResultSheet() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('紀錄');
  if (sheet) {
    const lastRow = sheet.getLastRow();
    if (lastRow >= 2) {
      sheet.getRange(2, 1, lastRow - 1, 3).clearContent(); // 清除從第 2 列開始的 A~C 欄資料
    }
  }
}

// 測試用函式:用來測試是否能成功取得回應
function testGetChatGPTResponse() {
  const query = "who is elon musk?";
  const result = getChatGPTResponse(query);
  Logger.log('測試結果:' + result);
}

index.html

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="UTF-8">
  <title>ChatGPT 對話系統</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
  <style>
    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background-color: #f8f9fa;
      padding-top: 50px;
    }
    #result {
      margin-top: 20px;
      text-align: left;
    }
    .spinner {
      display: none;
      position: absolute;
      top: 20%;
      left: 50%;
      transform: translate(-50%, -50%);
      border: 4px solid #f3f3f3;
      border-top: 4px solid #3498db;
      border-radius: 50%;
      width: 40px;
      height: 40px;
      animation: spin 1s linear infinite;
    }
    @keyframes spin {
      0% { transform: translate(-50%, -50%) rotate(0deg); }
      100% { transform: translate(-50%, -50%) rotate(360deg); }
    }
  </style>
</head>
<body>
  <div class="container">
    <h1 class="text-center mb-4">ChatGPT 對話系統</h1>
    <div class="card shadow p-4">
      <div class="mb-3">
        <label for="query" class="form-label">請輸入您的問題:</label>
        <textarea id="query" class="form-control" rows="4" placeholder="請在此輸入您的問題..."></textarea>
      </div>
      <div class="d-flex gap-2">
        <button class="btn btn-primary w-100" onclick="fetchResponse()">送出問題</button>
        <button class="btn btn-danger w-100" onclick="clearData()">清除紀錄</button>
      </div>
    </div>

    <!-- 載入中動畫 -->
    <div id="spinner" class="spinner"></div>

    <!-- 顯示回應區塊 -->
    <div id="result" class="mt-4"></div>
  </div>

  <script>
    // 傳送問題給伺服器並取得回應
    function fetchResponse() {
      const query = document.getElementById('query').value.trim();
      const resultDiv = document.getElementById('result');
      const spinner = document.getElementById('spinner');

      if (!query) {
        alert('請先輸入問題。');
        return;
      }

      resultDiv.textContent = '';
      spinner.style.display = 'inline-block';

      google.script.run
        .withSuccessHandler(() => {
          spinner.style.display = 'none';
          loadAllEntries();
        })
        .withFailureHandler((error) => {
          spinner.style.display = 'none';
          resultDiv.textContent = '錯誤:無法取得回應,請稍後再試。';
          console.error(error);
        })
        .getChatGPTResponse(query);
    }

    // 清除工作表中的所有紀錄
    function clearData() {
      const resultDiv = document.getElementById('result');
      const spinner = document.getElementById('spinner');

      resultDiv.textContent = '';
      spinner.style.display = 'inline-block';

      google.script.run
        .withSuccessHandler(() => {
          spinner.style.display = 'none';
          resultDiv.innerHTML = '<div class="alert alert-success">已成功清除所有資料。</div>';
        })
        .withFailureHandler((error) => {
          spinner.style.display = 'none';
          resultDiv.textContent = '錯誤:無法清除資料,請稍後再試。';
          console.error(error);
        })
        .clearResultSheet();
    }

    // 載入所有問答紀錄並以手風琴方式顯示
    function loadAllEntries() {
      const resultDiv = document.getElementById('result');
      const spinner = document.getElementById('spinner');

      spinner.style.display = 'inline-block';
      resultDiv.textContent = '';

      google.script.run
        .withSuccessHandler((entries) => {
          spinner.style.display = 'none';
          if (!entries || entries.length === 0) {
            resultDiv.innerHTML = '<div class="alert alert-info">目前尚無紀錄。</div>';
          } else {
            resultDiv.innerHTML = `
              <div class="accordion" id="queryAccordion">
                ${entries.map(([query, response], index) => `
                  <div class="accordion-item">
                    <h2 class="accordion-header" id="heading${index}">
                      <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse${index}" aria-expanded="false" aria-controls="collapse${index}">
                        ${index + 1}. 問題:${query}
                      </button>
                    </h2>
                    <div id="collapse${index}" class="accordion-collapse collapse" aria-labelledby="heading${index}" data-bs-parent="#queryAccordion">
                      <div class="accordion-body">
                        <p class="text-primary">回應:${response}</p>
                      </div>
                    </div>
                  </div>
                `).join('')}
              </div>
            `;
          }
        })
        .withFailureHandler((error) => {
          spinner.style.display = 'none';
          resultDiv.textContent = '錯誤:無法載入資料,請稍後再試。';
          console.error(error);
        })
        .getAllEntries();
    }

    // 載入頁面時自動執行
    window.onload = loadAllEntries;
  </script>
</body>
</html>

發表迴響