
升級版-長知柿:

透過 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>
