qGuide: OpenAI API にリクエスト (localStorage版) v202606

qGuide: OpenAI API にリクエスト (localStorage版) v202606

translate qGuide: Request to OpenAI API using localStorage v202606

指定データ項目に格納されているテキスト情報とファイル情報を Responses API に CORS 送信します。レスポンス(モデルが生成した文章)は、タスク処理画面上でストリーミング表示されます。プロンプト設定次第で、「誤植チェック機能」「文章リライト機能」「差戻理由の候補列挙機能」といった様々な支援機能(タスク処理者を支援する機能)を提供することが可能です。”APIキー” と “指示文” (最大8個)は、それぞれのユーザが localStorage に保存します。

Input / Output

  • user_config_area
  • user_buttons_area
  • user_files_area
  • ← localStorage
  • pre#user_result
  • div#user_status

Webストレージの機能およびリスクを理解したうえでご活用ください。

Code Example

HTML/JavaScript (click to open)
<style>
/* AI呼び出しボタン */
.user_aiBtn {
border: 1px solid #ccc;
padding: 6px 12px;
border-radius: 20px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
color: #333;
margin-right: 8px;
margin-bottom: 12px;
background-color: #fff;
font-family: system-ui, -apple-system, sans-serif;
}
.user_aiBtn:hover { background-color: #f0f0f0; border-color: #bbb; }
.user_aiBtn:disabled { opacity: 0.5; cursor: not-allowed; background-color: #eee; }

/* CONFIGボタン */
#user_btnConfig {
border: 1px solid #4a5568;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: all 0.2s ease;
color: #fff;
background-color: #4a5568;
margin-right: 16px;
margin-bottom: 12px;
font-family: system-ui, -apple-system, sans-serif;
}
#user_btnConfig:hover { background-color: #2d3748; border-color: #1a202c; }
#user_btnConfig.user_active { background-color: #2b6cb0; border-color: #2c5282; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); }

/* その他の設定エリア要素 */
.user_lbl {
display: block;
margin: 12px 0 4px 0;
font-weight: bold;
font-size: 13px;
font-family: system-ui, -apple-system, sans-serif;
}
.user_input {
width: 100%;
box-sizing: border-box;
padding: 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: inherit;
font-size: 13px;
margin-bottom: 4px;
}
textarea.user_input { resize: vertical; min-height: 4em; }
.user_actionBtn {
padding: 4px 12px;
border: 1px solid #bbb;
background-color: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
margin-right: 8px;
}
.user_actionBtn:hover { background-color: #eee; }
#user_result {
background-color: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
min-height: 8em;
white-space: pre-wrap;
word-break: break-word;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 14px;
line-height: 1.6;
color: #222;
margin-top: 16px;
}
.user_status_text {
font: 12px/1.4 system-ui, sans-serif;
opacity: 0.75;
margin-top: 8px;
min-height: 1.4em;
display: block;
}

/* ファイル一覧用スタイル */
.user_file_cb_label {
display: inline-block;
margin-right: 16px;
font-size: 13px;
cursor: pointer;
font-family: system-ui, -apple-system, sans-serif;
}
.user_file_cb {
margin-right: 4px;
vertical-align: middle;
}

@keyframes blink { 50% { opacity: 0; } }
.user_cursor {
display: inline-block;
width: 8px;
height: 1em;
background-color: #333;
margin-left: 2px;
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
}
</style>

<span id="user_files_area" style="display:none; margin-bottom:12px; background:#e2e8f0; padding:8px 12px; border-radius:4px;"></span>
<span id="user_buttons_area" style="display:block;"></span>
<span id="user_config_area" style="display:none; padding:16px; background:#f5f7fa; border: 1px solid #cbd5e0; border-radius:8px; margin-bottom:12px;"></span>
<pre id="user_result"></pre>
<span id="user_status" class="user_status_text"></span>

<script>
qbpms.form.on('ready', () => {
  // --- 0. 基本設定★★★ 編集してください / EDIT here ★★★ ---
  const TARGET_FIELD_NAMES = "title,q_body"; // カンマ区切りで複数指定可能
  const TARGET_FILES_FIELD_NAME = "q_attached"; // ファイル型フィールド名 (空文字で無効)
  const STORAGE_PREFIX = "user_inquiry_response_"; // localStorage Memory Prefix (アプリ・工程ごとなど)

  // --- デフォルト設定 ---
  const DEFAULT_MODEL = "gpt-5.4-mini";
  const DEFAULT_INST = [
    { title: "回答草稿500文字", body: "お客様からの「問い合わせタイトル」と「問い合わせ本文」を基に『回答文の草稿』を書いて。500文字程度で。" },
    { title: "景表法チェック", body: "景品表示法に照らして、「広告文案」を評価して。『合格・要修正・NG』の3段階で判定し、その理由を述べて。" },
    { title: "vCard生成", body: "名刺画像やメール本文から個人情報を抽出し、Google Contact (連絡先) 用の vCard 文字列にして。(.vcf ファイル用のデータ)" },
    { title: "論理飛躍の指摘", body: "論理飛躍を指摘してください。特に、前提不足、因果関係の弱さ、主語のすり替わり、抽象語によるごまかし、課題・解決策・効果の不一致を確認してください。読者が「なぜそう言えるのか?」と感じる箇所を列挙し、それぞれ不足している説明と改善案を簡潔に示してください。" },
    { title: "M5", body: "レビューして" },
    { title: "M6", body: "レビューして" },
    { title: "M7", body: "レビューして" },
    { title: "M8", body: "レビューして" }
  ];

  // ストレージキー
  const KEY_API = STORAGE_PREFIX + "openai_api_key";
  const KEY_MODEL = STORAGE_PREFIX + "openai_model";
  const KEY_INST_TITLE = STORAGE_PREFIX + "openai_inst_title_";
  const KEY_INST_BODY = STORAGE_PREFIX + "openai_inst_body_";

  const filesArea = document.getElementById("user_files_area");
  const buttonsArea = document.getElementById("user_buttons_area");
  const configArea = document.getElementById("user_config_area");
  const resultElement = document.getElementById("user_result");
  const statusElement = document.getElementById("user_status");
  
  // Questetraのコンテキストパス
  const qctx = window.qbpms?.contextPath || "";
  let isConfigOpen = false;

  // --- ユーティリティ: ArrayBuffer を Base64 に変換 ---
  function user_arrayBufferToBase64(buffer) {
    let binary = '';
    const bytes = new Uint8Array(buffer);
    const len = bytes.byteLength;
    for (let i = 0; i < len; i++) {
      binary += String.fromCharCode(bytes[i]);
    }
    return window.btoa(binary);
  }

  // --- ファイル一覧の描画関数 ---
  function renderFileList() {
    filesArea.innerHTML = "";
    if (!TARGET_FILES_FIELD_NAME) {
      filesArea.style.display = "none";
      return;
    }

    const files = qbpms.form.get(TARGET_FILES_FIELD_NAME);
    if (!files || files.length === 0) {
      filesArea.style.display = "none";
      return;
    }

    filesArea.style.display = "block";
    const lbl = document.createElement("span");
    lbl.className = "user_lbl";
    lbl.style.marginTop = "0";
    lbl.innerText = "Attachments (Check target files):";
    filesArea.appendChild(lbl);

    files.forEach((file) => {
      const label = document.createElement("label");
      label.className = "user_file_cb_label";

      const cb = document.createElement("input");
      cb.type = "checkbox";
      cb.value = file.id; 
      cb.dataset.filename = file.name;
      cb.dataset.pdiid = file.processDataInstanceId;
      cb.checked = true;
      cb.className = "user_file_cb";

      label.appendChild(cb);
      label.appendChild(document.createTextNode(file.name));
      filesArea.appendChild(label);
    });
  }

  // --- 1. 初期描画とファイル項目の変更監視 ---
  renderFileList();
  if (TARGET_FILES_FIELD_NAME) {
    qbpms.form.on('change', TARGET_FILES_FIELD_NAME, renderFileList);
  }

  // --- 2. メインボタン群の動的生成 ---
  const btnConfig = document.createElement("button");
  btnConfig.type = "button";
  btnConfig.id = "user_btnConfig";
  btnConfig.innerText = "CONFIG";
  buttonsArea.appendChild(btnConfig);

  const aiButtons = [];
  for (let i = 0; i < 8; i++) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "user_aiBtn";
    buttonsArea.appendChild(btn);
    aiButtons.push(btn);
  }

  // --- 3. CONFIGエリアの動的生成 ---
  const lblApi = document.createElement("span"); lblApi.className = "user_lbl"; lblApi.innerText = "APIキー / API Key (マスクなし):"; configArea.appendChild(lblApi);
  const inpApi = document.createElement("input"); inpApi.type = "text"; inpApi.className = "user_input"; inpApi.placeholder = "sk-..."; configArea.appendChild(inpApi);
  
  const lblModel = document.createElement("span");
    lblModel.className = "user_lbl";
    lblModel.innerHTML = 'モデル / Model: <a href="https://platform.openai.com/docs/models" target="_blank" rel="noopener noreferrer" style="font-weight: normal; font-size: 12px; margin-left: 12px; color: #2b6cb0; text-decoration: underline;">⇒ Available Models (OpenAI Docs)</a>';
  configArea.appendChild(lblModel);
  const inpModel = document.createElement("input"); inpModel.type = "text"; inpModel.className = "user_input"; inpModel.placeholder = "gpt-5.4"; configArea.appendChild(inpModel);

  const instInputs = [];
  for (let i = 0; i < 8; i++) {
    const lbl = document.createElement("span"); lbl.className = "user_lbl"; lbl.innerText = `指示文${i + 1} / Instruction ${i + 1}:`; configArea.appendChild(lbl);
    const inpT = document.createElement("input"); inpT.type = "text"; inpT.className = "user_input"; inpT.placeholder = "Instruction Title"; configArea.appendChild(inpT);
    const inpB = document.createElement("textarea"); inpB.className = "user_input"; inpB.placeholder = "Instruction Details"; configArea.appendChild(inpB);
    instInputs.push({ title: inpT, body: inpB });
  }

  const hr = document.createElement("hr"); hr.style.margin = "16px 0"; hr.style.borderTop = "1px dashed #ccc"; configArea.appendChild(hr);
  const btnSave = document.createElement("button"); btnSave.type = "button"; btnSave.className = "user_actionBtn"; btnSave.innerText = "設定を保存 / Save"; configArea.appendChild(btnSave);
  const btnClear = document.createElement("button"); btnClear.type = "button"; btnClear.className = "user_actionBtn"; btnClear.innerText = "初期化 / Reset"; configArea.appendChild(btnClear);
  const saveStatus = document.createElement("span"); saveStatus.style.color = "#0066cc"; saveStatus.style.fontWeight = "bold"; saveStatus.style.fontSize = "13px"; configArea.appendChild(saveStatus);

  // --- 4. 設定値の読み込み ---
  function loadSettings() {
    inpApi.value = localStorage.getItem(KEY_API) || "";
    inpModel.value = localStorage.getItem(KEY_MODEL) || DEFAULT_MODEL;
    for (let i = 0; i < 8; i++) {
      const storedT = localStorage.getItem(KEY_INST_TITLE + i);
      const storedB = localStorage.getItem(KEY_INST_BODY + i);
      instInputs[i].title.value = storedT !== null ? storedT : DEFAULT_INST[i].title;
      instInputs[i].body.value = storedB !== null ? storedB : DEFAULT_INST[i].body;
      aiButtons[i].innerText = instInputs[i].title.value;
    }
  }
  loadSettings();

  // --- 5. 各種イベントリスナー ---
  btnConfig.addEventListener("click", () => {
    isConfigOpen = !isConfigOpen;
    configArea.style.display = isConfigOpen ? "block" : "none";
    if (isConfigOpen) {
      btnConfig.classList.add("user_active");
      loadSettings();
    } else {
      btnConfig.classList.remove("user_active");
    }
  });

  btnSave.addEventListener("click", () => {
    localStorage.setItem(KEY_API, inpApi.value.trim());
    localStorage.setItem(KEY_MODEL, inpModel.value.trim());
    for (let i = 0; i < 8; i++) {
      localStorage.setItem(KEY_INST_TITLE + i, instInputs[i].title.value);
      localStorage.setItem(KEY_INST_BODY + i, instInputs[i].body.value);
      aiButtons[i].innerText = instInputs[i].title.value;
    }
    saveStatus.innerText = " 設定を保存しました / Saved";
    setTimeout(() => { saveStatus.innerText = ""; }, 2500);
  });

  btnClear.addEventListener("click", () => {
    if (!confirm("すべての設定をデフォルトに戻しますか? / Do you want to revert to the default?")) return;
    localStorage.removeItem(KEY_API);
    localStorage.removeItem(KEY_MODEL);
    for (let i = 0; i < 8; i++) {
      localStorage.removeItem(KEY_INST_TITLE + i);
      localStorage.removeItem(KEY_INST_BODY + i);
    }
    loadSettings();
    saveStatus.innerText = " 初期化しました / Initialized ";
    setTimeout(() => { saveStatus.innerText = ""; }, 2500);
  });

  // --- テキスト入力値の抽出 ---
  function getTargetTextInputs() {
    let combinedInput = "";
    if (TARGET_FIELD_NAMES) {
      const fields = TARGET_FIELD_NAMES.split(",");
      fields.forEach(f => {
        const val = qbpms.form.get(f.trim());
        if (val) combinedInput += `【${f.trim()}】\n${val}\n\n`;
      });
    }
    return combinedInput.trim();
  }

  // --- 6. AI呼び出し処理 ---
  aiButtons.forEach((btn, index) => {
    btn.addEventListener("click", async () => {
      const strKey = localStorage.getItem(KEY_API);
      const modelName = localStorage.getItem(KEY_MODEL) || DEFAULT_MODEL;
      const strInstruction = instInputs[index].body.value;
      const strInput = getTargetTextInputs();

      if (!strKey) {
        statusElement.innerText = "ERROR: The API key has not been set. Save the setting in CONFIG.";
        resultElement.innerText = "";
        return;
      }

      // 処理開始: UIの無効化
      aiButtons.forEach(b => b.disabled = true);
      btnConfig.disabled = true;
      resultElement.innerHTML = '';
      
      const contentParts = [];
      const uploadedFileIds = []; // クリーンアップ用のID保持配列
      let processHasError = false; // エラー表示の上書き防止フラグ

      if (strInput) {
        contentParts.push({ type: "input_text", text: strInput });
      }

      // --- [ファイル取得・アップロード処理] ---
      if (TARGET_FILES_FIELD_NAME) {
        const checkboxes = document.querySelectorAll(".user_file_cb");
        for (const cb of checkboxes) {
          if (cb.checked) {
            const qfileId = cb.value;
            const filename = cb.dataset.filename;
            const pdiId = cb.dataset.pdiid;

            statusElement.innerText = ` ${filename} downloading ...`;

            try {
              const fetchUrl = `${qctx}/API/OR/ProcessInstance/File/download?id=${encodeURIComponent(qfileId)}&processDataInstanceId=${encodeURIComponent(pdiId)}`;
              const dlRes = await fetch(fetchUrl);
              
              if (!dlRes.ok) {
                throw new Error(`HTTP Error: ${dlRes.status}`);
              }

              const contentType = dlRes.headers.get('content-type') || 'application/octet-stream';
              const buf = await dlRes.arrayBuffer();
              
              if (contentType.startsWith('image/')) {
                const base64 = user_arrayBufferToBase64(buf);
                const dataUrl = `data:${contentType};base64,${base64}`;
                contentParts.push({ type: "input_image", image_url: dataUrl });
                statusElement.innerText = `画像「${filename}」を添付しました。/ Attached`;
              } else {
                statusElement.innerText = ` ${filename} uploading ...`;
                const formData = new FormData();
                const blob = new Blob([new Uint8Array(buf)], { type: contentType });
                const fileObj = (typeof File !== 'undefined') ? new File([blob], filename, { type: contentType }) : blob;
                
                formData.append("purpose", "user_data");
                formData.append("file", fileObj, filename);
                
                const upRes = await fetch("https://api.openai.com/v1/files", {
                  method: "POST",
                  headers: { "Authorization": `Bearer ${strKey}` },
                  body: formData
                });
                
                if (!upRes.ok) {
                  let msg = `OpenAI File Upload Error: ${upRes.status}`;
                  try { msg += `\n${JSON.stringify(await upRes.json(), null, 2)}`; } catch (_) {}
                  throw new Error(msg);
                }
                
                const upJson = await upRes.json();
                const uploadedFileId = upJson.id; 
                
                uploadedFileIds.push(uploadedFileId); // クリーンアップ対象として保持
                contentParts.push({ type: 'input_file', file_id: uploadedFileId });
                statusElement.innerText = `ファイル「${filename}」を添付しました。/ Attached`;
              }
              
            } catch (error) {
              console.error("File fetch/upload error:", error);
              statusElement.innerText = `Error: Failed to process the file "${filename}".`;
              aiButtons.forEach(b => b.disabled = false);
              btnConfig.disabled = false;
              return;
            }
          }
        }
      }

      if (contentParts.length === 0) {
        statusElement.innerText = `Error: 対象フィールドまたはファイル が空です。/ The target field or file is empty.`;
        aiButtons.forEach(b => b.disabled = false);
        btnConfig.disabled = false;
        return;
      }

      statusElement.innerText = "AIに接続中... / Connecting to AI...";
      resultElement.innerHTML = '<span class="user_cursor"></span>';
      const cursor = resultElement.querySelector(".user_cursor");

      try {
        const response = await fetch("https://api.openai.com/v1/responses", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Accept": "text/event-stream",
            "Authorization": `Bearer ${strKey}`
          },
          body: JSON.stringify({
            model: modelName,
            instructions: strInstruction,
            input: [ { role: "user", content: contentParts } ],
            stream: true
          })
        });

        if (!response.ok) {
          let msg = `API Error: ${response.status}`;
          try {
            const err = await response.json();
            msg += `\n${JSON.stringify(err, null, 2)}`;
          } catch (_) {}
          throw new Error(msg);
        }
        
        statusElement.innerText = "AIが文章を生成中... / AI is generating text...";
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        let currentEvent = "";
        
        const flushLines = (chunkText) => {
          buffer += chunkText;
          const lines = buffer.split(/\r?\n/);
          buffer = lines.pop() || "";
          
          for (const line of lines) {
            if (!line.trim()) continue;
            
            if (line.startsWith("event:")) {
              currentEvent = line.replace(/^event:\s*/, "").trim();
              continue;
            }
            
            if (line.startsWith("data:")) {
              const dataRaw = line.replace(/^data:\s*/, "").trim();
              if (dataRaw === "[DONE]") return "DONE";
              
              let data = null;
              try {
                data = JSON.parse(dataRaw);
              } catch (e) {
                // パース失敗時は握りつぶさずコンソールに出力してスキップ
                console.error("SSE JSON parse error:", dataRaw, e);
                continue; 
              }

              // ★イベント処理とパースを分離
              if (currentEvent === "response.output_text.delta" && data.delta) {
                cursor.insertAdjacentText("beforebegin", data.delta);
              } else if (currentEvent === "response.completed") {
                return "DONE";
              } else if (currentEvent === "error" || currentEvent === "response.error") {
                // ここで throw すれば、外側の catch (error) で確実に補足・画面表示されます
                throw new Error(data?.error?.message || "Streaming API Error");
              }
            }
          }
        };

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          const text = decoder.decode(value, { stream: true });
          const status = flushLines(text);
          if (status === "DONE") break;
        }
        
      } catch (error) {
        processHasError = true;
        console.error("Request failed:", error);
        statusElement.innerText = "エラーが発生しました。 / An error occurred.";
        cursor?.insertAdjacentText("beforebegin", `\n[Exception] ${String(error.message || error)}`);
      } finally {
        const c = document.querySelector("#user_result .user_cursor");
        c?.remove();
        
        // ★アップロードしたファイルの削除クリーンアップ処理
        if (uploadedFileIds.length > 0 && strKey) {
          if (!processHasError) {
             statusElement.innerText = "生成完了 (一時ファイルを削除中...) / Cleaning up files...";
          }
          
          for (const fid of uploadedFileIds) {
            try {
              await fetch(`https://api.openai.com/v1/files/${fid}`, {
                method: "DELETE",
                headers: { "Authorization": `Bearer ${strKey}` }
              });
            } catch (cleanupErr) {
              console.error(`Failed to delete file ${fid}:`, cleanupErr);
            }
          }
        }

        if (!processHasError) {
          statusElement.innerText = "処理完了 / Completed";
        }

        aiButtons.forEach(b => b.disabled = false);
        btnConfig.disabled = false;
      }
    });
  });
});
</script>
自由改変可能な HTML/JavaScript コードです (MIT License)。いかなる保証もありません。
(JavaScript を用いたデコレーションProfessional editionでのみ利用可能です: M213)

Capture

Capture

See Also

Questetra Supportをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む