SCALE — Build Lab
開発パターン · JAVASCRIPT PATTERN

Claude API プロキシ + キャッシュ

CATEGORY開発パターン TYPEJavaScript Pattern EFFORT120〜300分 DIFFICULTY
PRIMARY CODE
js · scale-crm:ai.js
// =============== AI ENGINE (Claude API Integration) ===============
const AI_PROXY_URL='https://scale-ai-proxy.y-ogushi.workers.dev';

// =============== AI レスポンスキャッシュ(同一プロンプト再利用)===============
// 同じ system+user の組み合わせは24時間ローカルに保持
const AI_CACHE_KEY='ai_response_cache';
const AI_CACHE_TTL_MS=24*3600*1000;
const AI_CACHE_MAX=50;
function _aiCacheHash(s){
  // 簡易ハッシュ(FNV-1a相当)
  let h=2166136261;
  const str=String(s||'');
  for(let i=0;i<str.length;i++){h^=str.charCodeAt(i);h=(h*16777619)>>>0}
  return h.toString(36);
}
function _aiCacheKey(system,user,model){
  const u=typeof user==='string'?user:JSON.stringify(user);
  return _aiCacheHash(system)+'_'+_aiCacheHash(u)+'_'+(model||'');
}
function _aiCacheGet(key){
  try{
    const all=JSON.parse(localStorage.getItem(AI_CACHE_KEY)||'{}');
    const entry=all[key];
    if(!entry)return null;
    if(Date.now()-entry.t>AI_CACHE_TTL_MS){delete all[key];localStorage.setItem(AI_CACHE_KEY,JSON.stringify(all));return null}
    return entry.v;
  }catch(e){return null}
}
function _aiCacheSet(key,value){
  try{
    const all=JSON.parse(localStorage.getItem(AI_CACHE_KEY)||'{}');
    all[key]={v:value,t:Date.now()};
    // 超過したら古い順に削除
    const keys=Object.keys(all);
    if(keys.length>AI_CACHE_MAX){
      keys.sort((a,b)=>all[a].t-all[b].t).slice(0,keys.length-AI_CACHE_MAX).forEach(k=>delete all[k]);
    }
    localStorage.setItem(AI_CACHE_KEY,JSON.stringify(all));
  }catch(e){/* localStorage満杯でも無視 */}
}
function clearAICache(){try{localStorage.removeItem(AI_CACHE_KEY);if(typeof toast==='function')toast('AIキャッシュを削除')}catch(e){}}
function getAICacheStats(){
  try{
    const all=JSON.parse(localStorage.getItem(AI_CACHE_KEY)||'{}');
    const keys=Object.keys(all);
    return {count:keys.length,max:AI_CACHE_MAX,ttlHours:24};
  }catch(e){return {count:0,max:AI_CACHE_MAX,ttlHours:24}}
}

// タイムアウト付き fetch + 自動リトライ(ネットワーク失敗時のみ)
async function _fetchWithTimeout(url,opts,timeoutMs){
  const controller=new AbortController();
  const t=setTimeout(()=>controller.abort(),timeoutMs||180000); // 3分
  try{
    const res=await fetch(url,Object.assign({},opts,{signal:controller.signal}));
    clearTimeout(t);
    return res;
  }catch(e){
    clearTimeout(t);
    if(e.name==='AbortError')throw new Error('リクエストがタイムアウトしました(3分)');
    throw e;
  }
}

async function callClaude(systemPrompt,userMessage,opts={}){
  const content=Array.isArray(userMessage)?userMessage:[{type:'text',text:userMessage}];
  const primaryModel=opts.model||'claude-opus-4-7';
  // キャッシュチェック(opts.noCache=true で無効化、ファイル添付時は自動スキップ)
  const hasFiles=Array.isArray(userMessage)&&userMessage.some(b=>b&&(b.type==='image'||b.type==='document'));
  if(!opts.noCache&&!hasFiles){
    const ck=_aiCacheKey(systemPrompt,userMessage,primaryModel);
    const cached=_aiCacheGet(ck);
    if(cached){console.log('[AI Cache HIT]',ck);return cached}
    opts.__cacheKey=ck;
  }
  // フォールバック: Opus → Sonnet 4(もう少し軽量・高速)
  const fallbackModels=[primaryModel,'claude-sonnet-4-5'];
  const maxRetry=opts.maxRetry||2;
  let lastErr;

  // モデル毎に全リトライを試す
  for(let mIdx=0;mIdx<fallbackModels.length;mIdx++){
    const model=fallbackModels[mIdx];
    const body={model:model,max_tokens:opts.maxTokens||4096,system:systemPrompt,messages:[{role:'user',content}]};
    for(let attempt=0;attempt<=maxRetry;attempt++){
      try{
        const res=await _fetchWithTimeout(AI_PROXY_URL,{
          method:'POST',
          headers:{'Content-Type':'application/json'},
          body:JSON.stringify(body)
        },opts.timeoutMs||180000);
        if(!res.ok){
          const err=await res.json().catch(()=>({}));
          const msg=err.error?.message||`API Error: ${res.status}`;
          // 5xx系はリトライ、4xx系は即エラー
          if(res.status>=500&&attempt<maxRetry){lastErr=new Error(msg);await new Promise(r=>setTimeout(r,1500*(attempt+1)));continue}
          // 4xxで同モデルリトライしない場合、次モデルへ
          if(mIdx<fallbackModels.length-1){console.warn('callClaude '+model+' failed, falling back to '+fallbackModels[mIdx+1]+':',msg);lastErr=new Error(msg);break}
          throw new Error(msg);
        }
        const data=await res.json();
        const txt=data.content[0]?.text||'';
        if(opts.__cacheKey&&txt)_aiCacheSet(opts.__cacheKey,txt);
        return txt;
      }catch(e){
        lastErr=e;
        // Failed to fetch / TypeError はネットワーク失敗 → リトライ
        const msg=String(e.message||e);
        const isNetErr=msg.includes('Failed to fetch')||msg.includes('NetworkError')||msg.includes('タイムアウト')||e.name==='TypeError'||e.name==='AbortError';
        if(isNetErr&&attempt<maxRetry){
          console.warn('callClaude '+model+' retry '+(attempt+1)+'/'+maxRetry+':',msg);
          await new Promise(r=>setTimeout(r,1500*(attempt+1)));
          continue;
        }
        // リトライ尽きた → 次モデルへフォールバック
        if(mIdx<fallbackModels.length-1){console.warn('callClaude '+model+' exhausted retries, falling back to '+fallbackModels[mIdx+1]);break}
        throw e;
      }
    }
  }
  throw lastErr||new Error('AI呼び出しに失敗しました(全モデルで失敗)');
}

// File reading helpers for AI
function readFileAsBase64(file){
  return new Promise((resolve,reject)=>{
    const reader=new FileReader();
    reader.onload=()=>{const b64=reader.result.split(',')[1];resolve(b64)};
    reader.onerror=reject;
    reader.readAsDataURL(file);
  });
}
function readFileAsText(file){
  return new Promise((resolve,reject)=>{
    const reader=new FileReader();
    reader.onload=()=>resolve(reader.result);
    reader.onerror=reject;
    reader.readAsText(file);
  });
}
async function filesToContentBlocks(files){
  const blocks=[];
  for(const file of files){
    const ext=file.name.split('.').pop().toLowerCase();
    if(ext==='pdf'){
      const b64=await readFileAsBase64(file);
      blocks.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:b64}});
    }else if(['png','jpg','jpeg','gif','webp'].includes(ext)){
      const mimeMap={png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',gif:'image/gif',webp:'image/webp'};
      const b64=await readFileAsBase64(file);
      blocks.push({type:'image',source:{type:'base64',media_type:mimeMap[ext]||'image/png',data:b64}});
    }else if(['txt','csv','md','json'].includes(ext)){
      const text=await readFileAsText(file);
      blocks.push({type:'text',text:`【ファイル: ${file.name}】\n${text}`});
    }else{
      // Try reading as text for unknown formats
      try{const text=await readFileAsText(file);blocks.push({type:'text',text:`【ファイル: ${file.name}】\n${text}`})}catch(e){blocks.push({type:'text',text:`【ファイル: ${file.name}】(読み取り不可)`})}
    }
  }
  return blocks;
}

function showAILoading(msg){
  showModal(`<div class="modal-body" style="text-align:center;padding:40px"><div style="display:inline-block;width:40px;height:40px;border:3px solid var(--border2);border-top:3px solid var(--blue);border-radius:50%;animation:spin 1s linear infinite"></div><div class="ai-loading-text" style="margin-top:16px;font-size:14px;font-weight:600">${msg||'AI生成中...'}</div><div style="margin-top:8px;font-size:12px;color:var(--text3)">高品質な出力を生成しています。しばらくお待ちください。</div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style>`);
}

// === AI: Service Overview Auto-Fill ===
function getAISources(){return PD('ai_sources',{hpUrl:'',hpText:'',minutes:'',notes:''})}
function saveAISources(s){PS('ai_sources',s)}

function showAIServiceFill(){
  window._aiUploadedFiles=[];
  const saved=getAISources();
  const hasSaved=saved.hpUrl||saved.hpText||saved.minutes||saved.notes;
  showModal(`<div class="modal-header"><h3>AI自動入力 - サービス概要</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body">
  <div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7">
    資料やHP情報を入力すると、AIがサービス概要の全フィールドを自動で埋めます。<br>
    複数のソースを組み合わせるほど精度が上がります。
    ${hasSaved?'<br><span style="color:var(--green)">✅ 前回の入力内容を復元済み</span>':''}
  </div>
  <div class="form-row"><label>📎 資料ファイル(PDF / 画像 / テキスト)</label>
    <div id="ai_file_drop" style="border:2px dashed var(--border2);border-radius:10px;padding:24px;text-align:center;cursor:pointer;transition:border-color .2s;background:var(--bg)" onclick="document.getElementById('ai_file_input').click()" ondragover="event.preventDefault();this.style.borderColor='var(--blue)'" ondragleave="this.style.borderColor='var(--border2)'" ondrop="event.preventDefault();this.style.borderColor='var(--border2)';handleAIFileDrop(event.dataTransfer.files)">
      <div style="font-size:24px;margin-bottom:6px">📄</div>
      <div style="font-size:13px;font-weight:600;color:var(--text2)">クリックまたはドラッグ&ドロップ</div>
      <div style="font-size:11px;color:var(--text3);margin-top:4px">PDF, PNG, JPG, TXT, CSV 対応(複数可)</div>
    </div>
    <input type="file" id="ai_file_input" multiple accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,.txt,.csv,.md,.json" style="display:none" onchange="handleAIFileDrop(this.files)">
    <div id="ai_file_list" style="margin-top:8px"></div>
  </div>
  <div class="form-row"><label>HP URL</label><input id="ai_hp_url" value="${(saved.hpUrl||'').replace(/"/g,'&quot;')}" placeholder="https://example.com"></div>
  <div class="form-row"><label>会社HP / サービスページのテキスト</label><textarea id="ai_hp_text" rows="3" placeholder="HPからコピペ、またはサービス説明文を貼り付け">${saved.hpText||''}</textarea></div>
  <div class="form-row"><label>議事録 / ヒアリングメモ</label><textarea id="ai_minutes" rows="3" placeholder="クライアントとの打ち合わせ議事録、ヒアリング内容">${saved.minutes||''}</textarea></div>
  <div class="form-row"><label>申し送り事項 / 追加情報</label><textarea id="ai_notes" rows="2" placeholder="その他の補足情報(強み、注意点、過去実績等)">${saved.notes||''}</textarea></div>
  </div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">キャンセル</button><button class="btn btn-primary" onclick="execAIServiceFill()">AI自動入力を実行</button></div>`,true);
}

function showAIServiceEdit(){
  window._aiEditFiles=[];
  const svc=getSvc();
  if(!svc.productName&&!svc.overview)return alert('先にサービス概要を入力またはAI自動入力してください');
  showModal(`<div class="modal-header"><h3>AIに修正依頼</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body">
  <div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7">
    現在のサービス概要をベースに、AIが修正・追記します。<br>
    追加資料があればファイルも添付できます。
  </div>
  <div class="form-row"><label>📎 追加資料(PDF / 画像 / テキスト)</label>
    <div id="ai_edit_file_drop" style="border:2px dashed var(--border2);border-radius:10px;padding:18px;text-align:center;cursor:pointer;transition:border-color .2s;background:var(--bg)" onclick="document.getElementById('ai_edit_file_input').click()" ondragover="event.preventDefault();this.style.borderColor='var(--blue)'" ondragleave="this.style.borderColor='var(--border2)'" ondrop="event.preventDefault();this.style.borderColor='var(--border2)';handleAIEditFileDrop(event.dataTransfer.files)">
      <div style="font-size:13px;font-weight:600;color:var(--text2)">クリックまたはドラッグ&ドロップ</div>
      <div style="font-size:11px;color:var(--text3);margin-top:4px">PDF, PNG, JPG, TXT, CSV 対応(複数可)</div>
    </div>
    <input type="file" id="ai_edit_file_input" multiple accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,.txt,.csv,.md,.json" style="display:none" onchange="handleAIEditFileDrop(this.files)">
    <div id="ai_edit_file_list" style="margin-top:8px"></div>
  </div>
  <div class="form-row"><label>修正指示</label><textarea id="ai_edit_instruction" rows="4" placeholder="どう修正してほしいか、自由に書いてください" style="font-size:13px"></textarea></div>

  <!-- 既存データを破棄して新規作成モード -->
  <div style="margin-top:12px;padding:14px 16px;background:var(--bg2);border:1px solid var(--border);border-radius:10px">
    <label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer">
      <input type="checkbox" id="ai_edit_replace" style="margin-top:3px;accent-color:var(--red);width:16px;height:16px" onchange="_toggleAiEditReplaceWarning()">
      <div style="flex:1">
        <div style="font-size:13px;font-weight:700;color:var(--text);margin-bottom:2px">🔄 既存データを破棄して新規作成モード</div>
        <div style="font-size:11px;color:var(--text3);line-height:1.7">既存のサービス概要を全て削除してから、添付資料と修正指示だけをベースに新規作成します。<strong style="color:var(--text2)">完全に一新したい場合にチェック。</strong></div>
        <div id="ai_edit_replace_warn" style="display:none;margin-top:8px;padding:8px 12px;background:rgba(239,68,68,.08);border-left:2px solid var(--red);border-radius:6px;font-size:11px;color:var(--red);font-weight:600">⚠️ 既存の全データが削除されます。取り消し不可。</div>
      </div>
    </label>
  </div>

  <div style="background:var(--bg2);border-radius:10px;padding:12px 14px;margin-top:8px">
    <div style="font-size:10px;color:var(--text3);font-weight:600;margin-bottom:6px">💡 例</div>
    <div style="font-size:11px;color:var(--text2);line-height:1.8">
      ・この資料の内容でサービス概要を更新して<br>
      ・補助金が使えることを強みに追加して<br>
      ・競合の〇〇も追加して比較を充実させて<br>
      ・FAQに「解約条件は?」を追加して
    </div>
  </div>
  </div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">キャンセル</button><button class="btn btn-primary" onclick="execAIServiceEdit()">💬 AIに修正させる</button></div>`,true);
}
function _toggleAiEditReplaceWarning(){
  var cb=document.getElementById('ai_edit_replace');
  var w=document.getElementById('ai_edit_replace_warn');
  if(w)w.style.display=cb&&cb.checked?'block':'none';
}
function handleAIEditFileDrop(fileList){
  if(!fileList||!fileList.length)return;
  for(let i=0;i<fileList.length;i++)window._aiEditFiles.push(fileList[i]);
  renderAIEditFileList();
}
function removeAIEditFile(idx){window._aiEditFiles.splice(idx,1);renderAIEditFileList()}
function renderAIEditFileList(){
  const el=document.getElementById('ai_edit_file_list');if(!el)return;
  const files=window._aiEditFiles||[];
  if(!files.length){el.innerHTML='';return}
  const fmtSize=b=>b<1024?b+'B':b<1048576?(b/1024).toFixed(1)+'KB':(b/1048576).toFixed(1)+'MB';
  el.innerHTML=files.map((f,i)=>{
    return'<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg2);border-radius:8px;margin-bottom:4px;font-size:12px"><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">'+f.name+'</span><span style="color:var(--text3);font-size:10px">'+fmtSize(f.size)+'</span><button onclick="removeAIEditFile('+i+')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px;padding:0 4px">✕</button></div>'}).join('');
}

function showAIEditHistory(){
  const history=PD('ai_edit_history',[]);
  const fieldLabels={productName:'商材名',unitPrice:'単価',oneLineConcept:'一言コンセプト',overview:'概要',concept:'コンセプト',advantages:'競合優位性',weaknesses:'弱み',benefitList:'ベネフィット',preAdoptionConcerns:'導入前の不安',pricing:'料金体系',targetIndustry:'業界',targetRevenue:'売上規模',targetSize:'従業員規模',targetDept:'部署',targetRole:'役職',benefits:'ベネフィット',offer:'オファー',hearing:'ヒアリング',faq:'FAQ',ngWords:'NGワード',successCases:'成功事例',objectionHandling:'切り返し',appealWords:'訴求ワード',exclusionCriteria:'除外条件',shodan_person:'商談担当者',min_shodan_time:'最小商談時間',ideal_shodan_time:'理想商談時間'};
  showModal('<div class="modal-header"><h3>AI修正履歴</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body" style="max-height:70vh;overflow-y:auto">'
    +(history.length?history.slice().reverse().map(function(h,i){
      return'<div style="padding:14px;background:var(--bg2);border-radius:10px;margin-bottom:10px;border-left:3px solid var(--blue)"><div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"><span style="font-size:12px;font-weight:700;color:var(--text)">'+esc(h.date)+'</span><span style="font-size:11px;color:var(--text3)">'+esc(h.user)+'</span>'+(h.fileCount?'<span style="font-size:10px;padding:2px 6px;background:var(--bg3);border-radius:4px;color:var(--text2)">📎 '+h.fileCount+'件</span>':'')+'</div><div style="font-size:13px;color:var(--text);line-height:1.6;margin-bottom:6px">'+esc(h.instruction||'')+'</div><div style="display:flex;flex-wrap:wrap;gap:4px">'+(h.changedFields||[]).map(function(f){return'<span style="font-size:10px;padding:2px 8px;background:var(--blue-soft);color:var(--blue);border-radius:4px;font-weight:600">'+(fieldLabels[f]||f)+'</span>'}).join('')+'</div></div>'}).join(''):'<div style="text-align:center;padding:40px;color:var(--text3)"><div style="font-size:13px">まだAI修正履歴がありません</div><div style="font-size:11px;margin-top:4px">「AIに修正依頼」「AI自動入力」を実行すると履歴が記録されます</div></div>')
    +'</div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">閉じる</button>'+(history.length?'<button class="btn btn-danger btn-sm" onclick="if(confirm(\'履歴を全削除しますか?\')){PS(\'ai_edit_history\',[]);closeModal()}">履歴をクリア</button>':'')+'</div>',true);
}
// 堅牢なJSON抽出: コードブロック / 裸のJSON / 不正文字を段階的に試す
function _extractJsonFromText(text){
  if(!text)return null;
  // 1. 
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • AI機能を組み込む全システム

Claude API プロキシ + キャッシュ

:LiTarget: 用途

Claude API を Cloudflare Workers 経由で呼び、同一プロンプトを24時間ローカルキャッシュ。コスト削減。

:LiSparkle: 特徴

  • Claude API プロキシ Worker
  • 同一プロンプト 24時間キャッシュ
  • localStorage活用
  • API コスト削減

:LiCode: 実コード(SCALE Base より自動抽出)

:LiInfo: scale-crm:ai.js の中身そのもの。コピペ即可。

// =============== AI ENGINE (Claude API Integration) ===============
const AI_PROXY_URL='https://scale-ai-proxy.y-ogushi.workers.dev';

// =============== AI レスポンスキャッシュ(同一プロンプト再利用)===============
// 同じ system+user の組み合わせは24時間ローカルに保持
const AI_CACHE_KEY='ai_response_cache';
const AI_CACHE_TTL_MS=24*3600*1000;
const AI_CACHE_MAX=50;
function _aiCacheHash(s){
  // 簡易ハッシュ(FNV-1a相当)
  let h=2166136261;
  const str=String(s||'');
  for(let i=0;i<str.length;i++){h^=str.charCodeAt(i);h=(h*16777619)>>>0}
  return h.toString(36);
}
function _aiCacheKey(system,user,model){
  const u=typeof user==='string'?user:JSON.stringify(user);
  return _aiCacheHash(system)+'_'+_aiCacheHash(u)+'_'+(model||'');
}
function _aiCacheGet(key){
  try{
    const all=JSON.parse(localStorage.getItem(AI_CACHE_KEY)||'{}');
    const entry=all[key];
    if(!entry)return null;
    if(Date.now()-entry.t>AI_CACHE_TTL_MS){delete all[key];localStorage.setItem(AI_CACHE_KEY,JSON.stringify(all));return null}
    return entry.v;
  }catch(e){return null}
}
function _aiCacheSet(key,value){
  try{
    const all=JSON.parse(localStorage.getItem(AI_CACHE_KEY)||'{}');
    all[key]={v:value,t:Date.now()};
    // 超過したら古い順に削除
    const keys=Object.keys(all);
    if(keys.length>AI_CACHE_MAX){
      keys.sort((a,b)=>all[a].t-all[b].t).slice(0,keys.length-AI_CACHE_MAX).forEach(k=>delete all[k]);
    }
    localStorage.setItem(AI_CACHE_KEY,JSON.stringify(all));
  }catch(e){/* localStorage満杯でも無視 */}
}
function clearAICache(){try{localStorage.removeItem(AI_CACHE_KEY);if(typeof toast==='function')toast('AIキャッシュを削除')}catch(e){}}
function getAICacheStats(){
  try{
    const all=JSON.parse(localStorage.getItem(AI_CACHE_KEY)||'{}');
    const keys=Object.keys(all);
    return {count:keys.length,max:AI_CACHE_MAX,ttlHours:24};
  }catch(e){return {count:0,max:AI_CACHE_MAX,ttlHours:24}}
}

// タイムアウト付き fetch + 自動リトライ(ネットワーク失敗時のみ)
async function _fetchWithTimeout(url,opts,timeoutMs){
  const controller=new AbortController();
  const t=setTimeout(()=>controller.abort(),timeoutMs||180000); // 3分
  try{
    const res=await fetch(url,Object.assign({},opts,{signal:controller.signal}));
    clearTimeout(t);
    return res;
  }catch(e){
    clearTimeout(t);
    if(e.name==='AbortError')throw new Error('リクエストがタイムアウトしました(3分)');
    throw e;
  }
}

async function callClaude(systemPrompt,userMessage,opts={}){
  const content=Array.isArray(userMessage)?userMessage:[{type:'text',text:userMessage}];
  const primaryModel=opts.model||'claude-opus-4-7';
  // キャッシュチェック(opts.noCache=true で無効化、ファイル添付時は自動スキップ)
  const hasFiles=Array.isArray(userMessage)&&userMessage.some(b=>b&&(b.type==='image'||b.type==='document'));
  if(!opts.noCache&&!hasFiles){
    const ck=_aiCacheKey(systemPrompt,userMessage,primaryModel);
    const cached=_aiCacheGet(ck);
    if(cached){console.log('[AI Cache HIT]',ck);return cached}
    opts.__cacheKey=ck;
  }
  // フォールバック: Opus → Sonnet 4(もう少し軽量・高速)
  const fallbackModels=[primaryModel,'claude-sonnet-4-5'];
  const maxRetry=opts.maxRetry||2;
  let lastErr;

  // モデル毎に全リトライを試す
  for(let mIdx=0;mIdx<fallbackModels.length;mIdx++){
    const model=fallbackModels[mIdx];
    const body={model:model,max_tokens:opts.maxTokens||4096,system:systemPrompt,messages:[{role:'user',content}]};
    for(let attempt=0;attempt<=maxRetry;attempt++){
      try{
        const res=await _fetchWithTimeout(AI_PROXY_URL,{
          method:'POST',
          headers:{'Content-Type':'application/json'},
          body:JSON.stringify(body)
        },opts.timeoutMs||180000);
        if(!res.ok){
          const err=await res.json().catch(()=>({}));
          const msg=err.error?.message||`API Error: ${res.status}`;
          // 5xx系はリトライ、4xx系は即エラー
          if(res.status>=500&&attempt<maxRetry){lastErr=new Error(msg);await new Promise(r=>setTimeout(r,1500*(attempt+1)));continue}
          // 4xxで同モデルリトライしない場合、次モデルへ
          if(mIdx<fallbackModels.length-1){console.warn('callClaude '+model+' failed, falling back to '+fallbackModels[mIdx+1]+':',msg);lastErr=new Error(msg);break}
          throw new Error(msg);
        }
        const data=await res.json();
        const txt=data.content[0]?.text||'';
        if(opts.__cacheKey&&txt)_aiCacheSet(opts.__cacheKey,txt);
        return txt;
      }catch(e){
        lastErr=e;
        // Failed to fetch / TypeError はネットワーク失敗 → リトライ
        const msg=String(e.message||e);
        const isNetErr=msg.includes('Failed to fetch')||msg.includes('NetworkError')||msg.includes('タイムアウト')||e.name==='TypeError'||e.name==='AbortError';
        if(isNetErr&&attempt<maxRetry){
          console.warn('callClaude '+model+' retry '+(attempt+1)+'/'+maxRetry+':',msg);
          await new Promise(r=>setTimeout(r,1500*(attempt+1)));
          continue;
        }
        // リトライ尽きた → 次モデルへフォールバック
        if(mIdx<fallbackModels.length-1){console.warn('callClaude '+model+' exhausted retries, falling back to '+fallbackModels[mIdx+1]);break}
        throw e;
      }
    }
  }
  throw lastErr||new Error('AI呼び出しに失敗しました(全モデルで失敗)');
}

// File reading helpers for AI
function readFileAsBase64(file){
  return new Promise((resolve,reject)=>{
    const reader=new FileReader();
    reader.onload=()=>{const b64=reader.result.split(',')[1];resolve(b64)};
    reader.onerror=reject;
    reader.readAsDataURL(file);
  });
}
function readFileAsText(file){
  return new Promise((resolve,reject)=>{
    const reader=new FileReader();
    reader.onload=()=>resolve(reader.result);
    reader.onerror=reject;
    reader.readAsText(file);
  });
}
async function filesToContentBlocks(files){
  const blocks=[];
  for(const file of files){
    const ext=file.name.split('.').pop().toLowerCase();
    if(ext==='pdf'){
      const b64=await readFileAsBase64(file);
      blocks.push({type:'document',source:{type:'base64',media_type:'application/pdf',data:b64}});
    }else if(['png','jpg','jpeg','gif','webp'].includes(ext)){
      const mimeMap={png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',gif:'image/gif',webp:'image/webp'};
      const b64=await readFileAsBase64(file);
      blocks.push({type:'image',source:{type:'base64',media_type:mimeMap[ext]||'image/png',data:b64}});
    }else if(['txt','csv','md','json'].includes(ext)){
      const text=await readFileAsText(file);
      blocks.push({type:'text',text:`【ファイル: ${file.name}】\n${text}`});
    }else{
      // Try reading as text for unknown formats
      try{const text=await readFileAsText(file);blocks.push({type:'text',text:`【ファイル: ${file.name}】\n${text}`})}catch(e){blocks.push({type:'text',text:`【ファイル: ${file.name}】(読み取り不可)`})}
    }
  }
  return blocks;
}

function showAILoading(msg){
  showModal(`<div class="modal-body" style="text-align:center;padding:40px"><div style="display:inline-block;width:40px;height:40px;border:3px solid var(--border2);border-top:3px solid var(--blue);border-radius:50%;animation:spin 1s linear infinite"></div><div class="ai-loading-text" style="margin-top:16px;font-size:14px;font-weight:600">${msg||'AI生成中...'}</div><div style="margin-top:8px;font-size:12px;color:var(--text3)">高品質な出力を生成しています。しばらくお待ちください。</div></div><style>@keyframes spin{to{transform:rotate(360deg)}}</style>`);
}

// === AI: Service Overview Auto-Fill ===
function getAISources(){return PD('ai_sources',{hpUrl:'',hpText:'',minutes:'',notes:''})}
function saveAISources(s){PS('ai_sources',s)}

function showAIServiceFill(){
  window._aiUploadedFiles=[];
  const saved=getAISources();
  const hasSaved=saved.hpUrl||saved.hpText||saved.minutes||saved.notes;
  showModal(`<div class="modal-header"><h3>AI自動入力 - サービス概要</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body">
  <div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7">
    資料やHP情報を入力すると、AIがサービス概要の全フィールドを自動で埋めます。<br>
    複数のソースを組み合わせるほど精度が上がります。
    ${hasSaved?'<br><span style="color:var(--green)">✅ 前回の入力内容を復元済み</span>':''}
  </div>
  <div class="form-row"><label>📎 資料ファイル(PDF / 画像 / テキスト)</label>
    <div id="ai_file_drop" style="border:2px dashed var(--border2);border-radius:10px;padding:24px;text-align:center;cursor:pointer;transition:border-color .2s;background:var(--bg)" onclick="document.getElementById('ai_file_input').click()" ondragover="event.preventDefault();this.style.borderColor='var(--blue)'" ondragleave="this.style.borderColor='var(--border2)'" ondrop="event.preventDefault();this.style.borderColor='var(--border2)';handleAIFileDrop(event.dataTransfer.files)">
      <div style="font-size:24px;margin-bottom:6px">📄</div>
      <div style="font-size:13px;font-weight:600;color:var(--text2)">クリックまたはドラッグ&ドロップ</div>
      <div style="font-size:11px;color:var(--text3);margin-top:4px">PDF, PNG, JPG, TXT, CSV 対応(複数可)</div>
    </div>
    <input type="file" id="ai_file_input" multiple accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,.txt,.csv,.md,.json" style="display:none" onchange="handleAIFileDrop(this.files)">
    <div id="ai_file_list" style="margin-top:8px"></div>
  </div>
  <div class="form-row"><label>HP URL</label><input id="ai_hp_url" value="${(saved.hpUrl||'').replace(/"/g,'&quot;')}" placeholder="https://example.com"></div>
  <div class="form-row"><label>会社HP / サービスページのテキスト</label><textarea id="ai_hp_text" rows="3" placeholder="HPからコピペ、またはサービス説明文を貼り付け">${saved.hpText||''}</textarea></div>
  <div class="form-row"><label>議事録 / ヒアリングメモ</label><textarea id="ai_minutes" rows="3" placeholder="クライアントとの打ち合わせ議事録、ヒアリング内容">${saved.minutes||''}</textarea></div>
  <div class="form-row"><label>申し送り事項 / 追加情報</label><textarea id="ai_notes" rows="2" placeholder="その他の補足情報(強み、注意点、過去実績等)">${saved.notes||''}</textarea></div>
  </div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">キャンセル</button><button class="btn btn-primary" onclick="execAIServiceFill()">AI自動入力を実行</button></div>`,true);
}

function showAIServiceEdit(){
  window._aiEditFiles=[];
  const svc=getSvc();
  if(!svc.productName&&!svc.overview)return alert('先にサービス概要を入力またはAI自動入力してください');
  showModal(`<div class="modal-header"><h3>AIに修正依頼</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body">
  <div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7">
    現在のサービス概要をベースに、AIが修正・追記します。<br>
    追加資料があればファイルも添付できます。
  </div>
  <div class="form-row"><label>📎 追加資料(PDF / 画像 / テキスト)</label>
    <div id="ai_edit_file_drop" style="border:2px dashed var(--border2);border-radius:10px;padding:18px;text-align:center;cursor:pointer;transition:border-color .2s;background:var(--bg)" onclick="document.getElementById('ai_edit_file_input').click()" ondragover="event.preventDefault();this.style.borderColor='var(--blue)'" ondragleave="this.style.borderColor='var(--border2)'" ondrop="event.preventDefault();this.style.borderColor='var(--border2)';handleAIEditFileDrop(event.dataTransfer.files)">
      <div style="font-size:13px;font-weight:600;color:var(--text2)">クリックまたはドラッグ&ドロップ</div>
      <div style="font-size:11px;color:var(--text3);margin-top:4px">PDF, PNG, JPG, TXT, CSV 対応(複数可)</div>
    </div>
    <input type="file" id="ai_edit_file_input" multiple accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,.txt,.csv,.md,.json" style="display:none" onchange="handleAIEditFileDrop(this.files)">
    <div id="ai_edit_file_list" style="margin-top:8px"></div>
  </div>
  <div class="form-row"><label>修正指示</label><textarea id="ai_edit_instruction" rows="4" placeholder="どう修正してほしいか、自由に書いてください" style="font-size:13px"></textarea></div>

  <!-- 既存データを破棄して新規作成モード -->
  <div style="margin-top:12px;padding:14px 16px;background:var(--bg2);border:1px solid var(--border);border-radius:10px">
    <label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer">
      <input type="checkbox" id="ai_edit_replace" style="margin-top:3px;accent-color:var(--red);width:16px;height:16px" onchange="_toggleAiEditReplaceWarning()">
      <div style="flex:1">
        <div style="font-size:13px;font-weight:700;color:var(--text);margin-bottom:2px">🔄 既存データを破棄して新規作成モード</div>
        <div style="font-size:11px;color:var(--text3);line-height:1.7">既存のサービス概要を全て削除してから、添付資料と修正指示だけをベースに新規作成します。<strong style="color:var(--text2)">完全に一新したい場合にチェック。</strong></div>
        <div id="ai_edit_replace_warn" style="display:none;margin-top:8px;padding:8px 12px;background:rgba(239,68,68,.08);border-left:2px solid var(--red);border-radius:6px;font-size:11px;color:var(--red);font-weight:600">⚠️ 既存の全データが削除されます。取り消し不可。</div>
      </div>
    </label>
  </div>

  <div style="background:var(--bg2);border-radius:10px;padding:12px 14px;margin-top:8px">
    <div style="font-size:10px;color:var(--text3);font-weight:600;margin-bottom:6px">💡 例</div>
    <div style="font-size:11px;color:var(--text2);line-height:1.8">
      ・この資料の内容でサービス概要を更新して<br>
      ・補助金が使えることを強みに追加して<br>
      ・競合の〇〇も追加して比較を充実させて<br>
      ・FAQに「解約条件は?」を追加して
    </div>
  </div>
  </div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">キャンセル</button><button class="btn btn-primary" onclick="execAIServiceEdit()">💬 AIに修正させる</button></div>`,true);
}
function _toggleAiEditReplaceWarning(){
  var cb=document.getElementById('ai_edit_replace');
  var w=document.getElementById('ai_edit_replace_warn');
  if(w)w.style.display=cb&&cb.checked?'block':'none';
}
function handleAIEditFileDrop(fileList){
  if(!fileList||!fileList.length)return;
  for(let i=0;i<fileList.length;i++)window._aiEditFiles.push(fileList[i]);
  renderAIEditFileList();
}
function removeAIEditFile(idx){window._aiEditFiles.splice(idx,1);renderAIEditFileList()}
function renderAIEditFileList(){
  const el=document.getElementById('ai_edit_file_list');if(!el)return;
  const files=window._aiEditFiles||[];
  if(!files.length){el.innerHTML='';return}
  const fmtSize=b=>b<1024?b+'B':b<1048576?(b/1024).toFixed(1)+'KB':(b/1048576).toFixed(1)+'MB';
  el.innerHTML=files.map((f,i)=>{
    return'<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg2);border-radius:8px;margin-bottom:4px;font-size:12px"><span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">'+f.name+'</span><span style="color:var(--text3);font-size:10px">'+fmtSize(f.size)+'</span><button onclick="removeAIEditFile('+i+')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px;padding:0 4px">✕</button></div>'}).join('');
}

function showAIEditHistory(){
  const history=PD('ai_edit_history',[]);
  const fieldLabels={productName:'商材名',unitPrice:'単価',oneLineConcept:'一言コンセプト',overview:'概要',concept:'コンセプト',advantages:'競合優位性',weaknesses:'弱み',benefitList:'ベネフィット',preAdoptionConcerns:'導入前の不安',pricing:'料金体系',targetIndustry:'業界',targetRevenue:'売上規模',targetSize:'従業員規模',targetDept:'部署',targetRole:'役職',benefits:'ベネフィット',offer:'オファー',hearing:'ヒアリング',faq:'FAQ',ngWords:'NGワード',successCases:'成功事例',objectionHandling:'切り返し',appealWords:'訴求ワード',exclusionCriteria:'除外条件',shodan_person:'商談担当者',min_shodan_time:'最小商談時間',ideal_shodan_time:'理想商談時間'};
  showModal('<div class="modal-header"><h3>AI修正履歴</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body" style="max-height:70vh;overflow-y:auto">'
    +(history.length?history.slice().reverse().map(function(h,i){
      return'<div style="padding:14px;background:var(--bg2);border-radius:10px;margin-bottom:10px;border-left:3px solid var(--blue)"><div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"><span style="font-size:12px;font-weight:700;color:var(--text)">'+esc(h.date)+'</span><span style="font-size:11px;color:var(--text3)">'+esc(h.user)+'</span>'+(h.fileCount?'<span style="font-size:10px;padding:2px 6px;background:var(--bg3);border-radius:4px;color:var(--text2)">📎 '+h.fileCount+'件</span>':'')+'</div><div style="font-size:13px;color:var(--text);line-height:1.6;margin-bottom:6px">'+esc(h.instruction||'')+'</div><div style="display:flex;flex-wrap:wrap;gap:4px">'+(h.changedFields||[]).map(function(f){return'<span style="font-size:10px;padding:2px 8px;background:var(--blue-soft);color:var(--blue);border-radius:4px;font-weight:600">'+(fieldLabels[f]||f)+'</span>'}).join('')+'</div></div>'}).join(''):'<div style="text-align:center;padding:40px;color:var(--text3)"><div style="font-size:13px">まだAI修正履歴がありません</div><div style="font-size:11px;margin-top:4px">「AIに修正依頼」「AI自動入力」を実行すると履歴が記録されます</div></div>')
    +'</div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">閉じる</button>'+(history.length?'<button class="btn btn-danger btn-sm" onclick="if(confirm(\'履歴を全削除しますか?\')){PS(\'ai_edit_history\',[]);closeModal()}">履歴をクリア</button>':'')+'</div>',true);
}
// 堅牢なJSON抽出: コードブロック / 裸のJSON / 不正文字を段階的に試す
function _extractJsonFromText(text){
  if(!text)return null;
  // 1. ```json ... ``` ブロック(優先)
  var m=text.match(/```(?:json)?\s*([\s\S]*?)```/);
  var candidates=[];
  if(m)candidates.push(m[1]);
  // 2. 最初の { から最後の } まで
  var first=text.indexOf('{');
  var last=text.lastIndexOf('}');
  if(first>=0&&last>first)candidates.push(text.substring(first,last+1));
  // 3. テキスト全体
  candidates.push(text);
  for(var i=0;i<candidates.length;i++){
    var raw=candidates[i].trim();
    // 制御文字除去
    raw=raw.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g,'');
    // 末尾カンマ除去
    raw=raw.replace(/,(\s*[}\]])/g,'$1');
    try{return JSON.parse(raw)}catch(e){}
  }
  return null;
}

async function execAIServiceEdit(){
  const instruction=document.getElementById('ai_edit_instruction')?.value.trim();
  const files=window._aiEditFiles||[];
  const replaceMode=document.getElementById('ai_edit_replace')?.checked||false;
  if(!instruction&&!files.length)return alert('修正指示を入力するか、ファイルを添付してください');

  // replaceMode の最終確認
  if(replaceMode){
    if(!confirm('【確認】既存のサービス概要を全て破棄してから新規作成します。\n\n現在の入力内容(商材名・ターゲット・競合・ペルソナ等)が全て上書きされます。\n\n続行しますか?'))return;
  }

  // ファイルサイズチェック(合計20MB超で警告)
  if(files.length){
    var totalSize=0;for(var i=0;i<files.length;i++)totalSize+=files[i].size;
    if(totalSize>20*1024*1024){
      if(!confirm('添付ファイルの合計サイズが '+Math.round(totalSize/1024/1024)+'MB と大きく、処理に時間がかかるか失敗する可能性があります。それでも続行しますか?\n\n推奨: 10MB以下 / PDFは主要ページのみ抜粋'))return;
    }
  }

  showAILoading(replaceMode?'既存データをクリア→新規作成中...':'修正中...');
  try{
    // replaceMode: 既存データを初期値にリセットしてからAIに渡す(バックアップ付き)
    var svc=replaceMode?JSON.parse(JSON.stringify(SVC_DEFAULT)):getSvc();
    if(replaceMode){
      // 自動バックアップしてから初期化
      try{if(typeof _svcTakeSnapshot==='function')_svcTakeSnapshot('AI情報更新(全リセット)前 自動バックアップ')}catch(e){}
      PS('service',svc);
    }
    // svcが大きすぎる場合、クライアント企業情報やナレッジ等を除外してサイズを抑える
    var svcForAI=JSON.parse(JSON.stringify(svc));
    // 巨大配列は先頭のみに絞る(トークン節約)
    ['faq','successCases','memberInsights','approachTips','effectiveWords','pastClosedCompanies','callKnowledge','updates'].forEach(function(k){
      if(Array.isArray(svcForAI[k])&&svcForAI[k].length>10)svcForAI[k]=svcForAI[k].slice(0,10);
    });
    const svcJson=JSON.stringify(svcForAI,null,2);
    // Build file content blocks
    const srcBlocks=[];
    if(files.length){
      try{const fb=await filesToContentBlocks(files);srcBlocks.push(...fb)}
      catch(fe){throw new Error('ファイル読み込み失敗: '+(fe.message||fe))}
    }
    var instText=instruction||'添付資料の内容をサービス概要に反映してください。新しい情報は追加し、既存情報はより正確に更新してください。';

    updateAIProgress(1,2,'Step 1/2: 資料を読み込み中...');

    // === 1パスで修正を実行 ===
    var systemPrompt='あなたはBtoB営業支援のプロフェッショナルPMです。クライアントのサービス概要データを、ユーザーの修正指示と追加資料に基づいて修正します。\n\n## ルール\n- 既存の良い内容は絶対に消さない(空文字や「-」で上書きしない)\n- 添付資料の具体的な数値・固有名詞・事実を積極的に取り込む\n- 空欄だった項目も、資料や修正指示の文脈から埋められるなら埋める\n- 営業現場で実際に使える具体的な表現にする\n- JSONの構造(フィールド名・配列/オブジェクトの形)は元のまま維持する\n- 配列フィールド(benefitList, advantages, weaknesses等)は必ず配列のまま\n- 出力は完全なJSONを ```json コードブロックで囲む\n\n修正後のJSONのみを出力してください(説明文は不要)。';
    var userBlocks=[...srcBlocks,{type:'text',text:'【現在のサービス概要データ】\n```json\n'+svcJson+'\n```\n\n'+(files.length?'【添付資料】上記'+files.length+'件のファイルを参照してください。\n\n':'')+'【修正指示】\n'+instText+'\n\n上記を踏まえて、修正後の完全なJSONを出力してください。'}];

    updateAIProgress(2,2,'Step 2/2: データを修正中...(ファイルが多い場合1〜2分かかります)');
    var raw=await callClaude(systemPrompt,userBlocks,{maxTokens:4096,timeoutMs:240000,maxRetry:2});
    var parsed=_extractJsonFromText(raw);
    if(!parsed||typeof parsed!=='object'){
      throw new Error('AIの出力からJSONを抽出できませんでした。もう一度お試しください。');
    }

    // Merge with existing to preserve fields AI might have dropped
    const existing=getSvc();
    Object.keys(parsed).forEach(function(k){
      if(parsed[k]===undefined||parsed[k]===null)return;
      // 空文字で既存を上書きしない
      if(typeof parsed[k]==='string'&&!parsed[k].trim()&&existing[k])return;
      // 空配列で既存配列を上書きしない
      if(Array.isArray(parsed[k])&&!parsed[k].length&&Array.isArray(existing[k])&&existing[k].length)return;
      existing[k]=parsed[k];
    });
    // Ensure array fields stay as arrays
    ['benefitList','advantages','weaknesses','preAdoptionConcerns','faq','successCases','ngWords','hearingItems','objectionHandling','memberInsights','approachTips','effectiveWords','updates','callKnowledge','campaigns','personas','beforeAfter','competitors','challengeHypothesis','pastClosedCompanies'].forEach(function(k){
      if(existing[k]&&!Array.isArray(existing[k])){
        if(typeof existing[k]==='string'&&existing[k].trim())existing[k]=[existing[k]];
        else existing[k]=[];
      }
    });

    // Save edit history
    var history=PD('ai_edit_history',[]);
    var changedFields=[];
    Object.keys(parsed).forEach(function(k){if(JSON.stringify(svc[k])!==JSON.stringify(existing[k]))changedFields.push(k)});
    history.push({date:new Date().toISOString().slice(0,16).replace('T',' '),instruction:instText,fileCount:files.length,changedFields:changedFields,user:currentUser||''});
    PS('ai_edit_history',history);

    PS('service',existing);
    closeModal();
    showModal('<div class="modal-header"><h3>✅ 修正完了</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body"><div style="font-size:13px;color:var(--text2);line-height:1.7">サービス概要の修正を完了しました。'+(files.length?'<br>📎 '+files.length+'件の資料を反映済み':'')+'<br><br>'+(instruction?'<strong>修正指示:</strong> '+esc(instruction)+'<br><br>':'')+'<strong>変更項目:</strong> '+changedFields.length+'件<br>各項目を確認して、必要に応じて手動で微調整してください。</div></div><div class="modal-footer"><button class="btn btn-primary" onclick="closeModal();renderService()">確認する</button></div>',true);
  }catch(e){
    closeModal();
    console.error('AI修正エラー:',e);
    alert('AI修正エラー: '+(e.message||e)+'\n\nもう一度お試しいただくか、修正指示を短く具体的にしてみてください。');
  }
}
function handleAIFileDrop(fileList){
  if(!fileList||!fileList.length)return;
  for(let i=0;i<fileList.length;i++)window._aiUploadedFiles.push(fileList[i]);
  renderAIFileList();
}
function removeAIFile(idx){window._aiUploadedFiles.splice(idx,1);renderAIFileList()}
function renderAIFileList(){
  const el=document.getElementById('ai_file_list');if(!el)return;
  const files=window._aiUploadedFiles||[];
  if(!files.length){el.innerHTML='';return}
  const fmtSize=b=>b<1024?b+'B':b<1048576?(b/1024).toFixed(1)+'KB':(b/1048576).toFixed(1)+'MB';
  const iconMap={pdf:'📕',png:'🖼️',jpg:'🖼️',jpeg:'🖼️',gif:'🖼️',webp:'🖼️',txt:'📝',csv:'📊',md:'📝',json:'📋'};
  el.innerHTML=files.map((f,i)=>{
    const ext=f.name.split('.').pop().toLowerCase();
    return`<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg2);border-radius:8px;margin-bottom:4px;font-size:12px">
      <span>${iconMap[ext]||'📄'}</span>
      <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${f.name}</span>
      <span style="color:var(--text3);font-size:10px">${fmtSize(f.size)}</span>
      <button onclick="removeAIFile(${i})" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px;padding:0 4px">✕</button>
    </div>`}).join('');
}

function updateAIProgress(step,total,msg){
  const el=document.querySelector('.ai-loading-text');
  if(el)el.innerHTML='<div style="margin-bottom:8px">'+msg+'</div><div style="background:var(--border);border-radius:4px;height:6px;width:200px;margin:0 auto"><div style="background:var(--blue);height:100%;border-radius:4px;width:'+Math.round(step/total*100)+'%;transition:width .5s"></div></div><div style="font-size:11px;color:var(--text3);margin-top:6px">ステップ '+step+' / '+total+'</div>';
}
async function execAIServiceFill(){
  const hpUrl=document.getElementById('ai_hp_url')?.value.trim()||'';
  const hpText=document.getElementById('ai_hp_text')?.value.trim()||'';
  const minutes=document.getElementById('ai_minutes')?.value.trim()||'';
  const notes=document.getElementById('ai_notes')?.value.trim()||'';
  const files=window._aiUploadedFiles||[];
  if(!hpText&&!minutes&&!notes&&!files.length)return alert('少なくとも1つのソース(ファイルまたはテキスト)を入力してください');
  saveAISources({hpUrl,hpText,minutes,notes});
  showAILoading('高品質モードで生成中(4ステップ)...');
  try{
    const svc=getSvc();
    // Build source content blocks (shared across passes)
    const srcBlocks=[];
    if(files.length){const fb=await filesToContentBlocks(files);srcBlocks.push(...fb)}
    let srcText='';
    if(files.length)srcText+='【アップロードされた資料ファイル】上記'+files.length+'件のファイルの内容を参照してください。\n\n';
    if(hpUrl)srcText+='【HP URL】'+hpUrl+'\n\n';
    if(hpText)srcText+='【HP/サービスページのテキスト】\n'+hpText+'\n\n';
    if(minutes)srcText+='【議事録/ヒアリングメモ】\n'+minutes+'\n\n';
    if(notes)srcText+='【申し送り事項】\n'+notes+'\n\n';

    // === PASS 1: 基本情報の構造化 ===
    updateAIProgress(1,4,'Step 1/4: 資料を読み込み、基本情報を構造化中...');
    const p1System='あなたはBtoB営業支援のプロフェッショナルです。テレアポ代行会社「SCALE」のPMがクライアントのサービス情報を整理するのを支援します。\n\n与えられた情報源から、サービスの基本情報を徹底的に抽出・構造化してください。\n\n## 重要\n- 情報源に明記されていない項目も、業界知識から合理的に推測して必ず埋める\n- 空欄は絶対に作らない\n- 営業目線で整理する(テレアポで使える形に)\n- 具体的な数値を可能な限り含める\n\n## 出力形式(JSON)\n```json\n{\n  "productName": "商材名",\n  "unitPrice": "料金体系",\n  "overview": "サービス概要(5文以上で詳細に)",\n  "concept": "コンセプト/タグライン",\n  "oneLineConcept": "一言コンセプト(15文字以内)",\n  "advantages": ["優位性1(数値込み)","優位性2","優位性3"],\n  "weaknesses": ["弱み1","弱み2","弱み3"],\n  "benefits": "導入メリット(定量的に)",\n  "benefitList": ["ベネフィット1(数値込み)","ベネフィット2","ベネフィット3"],\n  "preAdoptionConcerns": ["導入障壁1","障壁2","障壁3"],\n  "offer": "架電時のオファー内容",\n  "hearing": "ヒアリング項目",\n  "pricing": "詳細な料金体系",\n  "clientProfile": {"name":"企業名","business":"事業内容","url":"HP","founded":"設立年"},\n  "appealWords": "訴求ワード(複数のキャッチフレーズ)"\n}\n```\n必ずJSON形式のみをコードブロックで出力。';
    const p1Blocks=[...srcBlocks,{type:'text',text:srcText+'\n上記の情報源からサービスの基本情報を抽出・構造化してください。'}];
    const r1=await callClaude(p1System,p1Blocks,{maxTokens:4000});
    const m1=r1.match(/```(?:json)?\s*([\s\S]*?)```/);
    const pass1=m1?JSON.parse(m1[1]):{};

    // === PASS 2: ターゲット・ペルソナ・課題仮説 ===
    updateAIProgress(2,4,'Step 2/4: ターゲット分析・ペルソナ設計中...');
    const p2System='あなたはBtoB営業のターゲティング専門家です。サービス情報をもとに、最適なターゲット設計とペルソナを作成してください。\n\n## 重要\n- ペルソナは決裁者・窓口担当者・現場担当者の3種類すべて、具体的な人物像が浮かぶレベルで\n- 課題仮説は3つ以上、各々「課題→原因→ゴール」の流れで\n- ターゲット条件は具体的な数値(売上○億以上、従業員○人以上等)で\n- Before/Afterは業種違いで3事例、定量的な改善指標付き\n\n## 出力形式(JSON)\n```json\n{\n  "targetRevenue":"売上規模",\n  "targetIndustry":"業界",\n  "targetDept":"部署",\n  "targetRole":"役職",\n  "targetSize":"従業員規模",\n  "challengeHypothesis":[{"challenge":"課題","cause":"原因","goal":"ゴール"}],\n  "wonIndustries":"受注実績業種",\n  "idealList":"理想リスト条件",\n  "targetNgList":"NGリスト条件",\n  "exclusionCriteria":"除外条件",\n  "personas":[{"type":"決裁者","role":"","age":"","issues":"","interest":"","keywords":""},{"type":"窓口担当者","role":"","age":"","issues":"","interest":"","keywords":""},{"type":"現場担当者","role":"","age":"","issues":"","interest":"","keywords":""}],\n  "beforeAfter":[{"type":"業種","before":"導入前","after":"導入後","metric":"改善指標"}]\n}\n```\n必ずJSON形式のみをコードブロックで出力。';
    const p2Msg='【サービス基本情報】\n```json\n'+JSON.stringify(pass1,null,2)+'\n```\n\n'+srcText+'\n上記をもとにターゲット設計・ペルソナ・課題仮説を作成してください。';
    const p2Blocks=[...srcBlocks,{type:'text',text:p2Msg}];
    const r2=await callClaude(p2System,p2Blocks,{maxTokens:5000});
    const m2=r2.match(/```(?:json)?\s*([\s\S]*?)```/);
    const pass2=m2?JSON.parse(m2[1]):{};

    // === PASS 3: 競合分析・ナレッジ・営業ツール ===
    updateAIProgress(3,4,'Step 3/4: 競合分析・営業ナレッジ生成中...');
    const p3System='あなたはBtoB競合分析と営業ナレッジ構築の専門家です。サービス情報をもとに、競合分析と営業支援ツールを作成してください。\n\n## 重要\n- 競合は実在する3社を特定し、全フィールドを埋める\n- 比較軸は5つ以上(価格、機能、サポート、実績、導入しやすさ等)\n- FAQ は営業電話で実際に聞かれる質問を5つ以上\n- 反論対策は「今は忙しい」「予算がない」「他社で検討中」等の典型パターンを5つ以上\n- ヒアリング項目は商談で確認すべきことを5つ以上、各々の意図付き\n- NGワードは3つ以上\n- 成功事例は3件以上\n\n## 出力形式(JSON)\n```json\n{\n  "competitors":[{"name":"","feature":"","price":"","support":"","record":"","strength":"","weakness":""}],\n  "compAxes":[{"axis":"比較軸","us":"自社","them":"競合"}],\n  "industryMap":"業界マップ/市場規模",\n  "faq":[{"q":"質問","a":"回答"}],\n  "ngWords":["NG表現"],\n  "successCases":[{"company":"企業名/業種","result":"成果","point":"ポイント"}],\n  "hearingItems":[{"question":"質問","purpose":"意図"}],\n  "objectionHandling":[{"objection":"反論","response":"切り返し"}],\n  "approachTips":[{"tip":"コツ"}],\n  "effectiveWords":["効果的フレーズ"],\n  "campaigns":[{"name":"名前","detail":"内容","period":"期間"}]\n}\n```\n必ずJSON形式のみをコードブロックで出力。';
    const p3Msg='【サービス基本情報】\n```json\n'+JSON.stringify(pass1,null,2)+'\n```\n【ターゲット情報】\n```json\n'+JSON.stringify(pass2,null,2)+'\n```\n\n'+srcText+'\n上記をもとに競合分析・営業ナレッジを作成してください。';
    const p3Blocks=[...srcBlocks,{type:'text',text:p3Msg}];
    const r3=await callClaude(p3System,p3Blocks,{maxTokens:6000});
    const m3=r3.match(/```(?:json)?\s*([\s\S]*?)```/);
    const pass3=m3?JSON.parse(m3[1]):{};

    // === PASS 4: 品質チェック・統合 ===
    updateAIProgress(4,4,'Step 4/4: 全体の品質チェック・統合中...');
    const combined={...pass1,...pass2,...pass3};
    const p4System='あなたはBtoB営業資料の品質管理の専門家です。以下のサービス概要データの品質チェックと改善を行ってください。\n\n## チェック項目\n1. 全項目が埋まっているか(空欄""があれば埋める)\n2. 内容の整合性(矛盾がないか)\n3. 営業現場で実際に使える具体性があるか\n4. 数値・実績が十分に含まれているか\n5. ターゲットとペルソナの整合性\n6. 競合との差別化が明確か\n7. FAQ・反論対策が実践的か\n\n## ルール\n- 品質が低い項目は改善して書き直す\n- 足りない項目は追加する\n- 元データの構造は維持する\n- 出力は完全なJSONとして出力(改善後の全データ)\n\n必ず完全なJSONをコードブロックで出力してください。';
    const p4Msg='以下のサービス概要データの品質チェック・改善を行い、完全なJSONとして出力してください。\n\n```json\n'+JSON.stringify(combined,null,2)+'\n```';
    const r4=await callClaude(p4System,p4Msg,{maxTokens:8000});
    const m4=r4.match(/```(?:json)?\s*([\s\S]*?)```/);
    const final=m4?JSON.parse(m4[1]):combined;

    // Merge with existing
    const merged={...svc};
    Object.keys(final).forEach(function(k){
      if(Array.isArray(final[k])){
        if(final[k].length>0&&JSON.stringify(final[k])!==JSON.stringify(SVC_DEFAULT[k]))merged[k]=final[k];
      }else if(typeof final[k]==='object'&&final[k]!==null){
        if(JSON.stringify(final[k])!==JSON.stringify(SVC_DEFAULT[k]))merged[k]=final[k];
      }else if(final[k]&&final[k]!==''){
        merged[k]=final[k];
      }
    });
    // Save auto-fill history
    var afHistory=PD('ai_edit_history',[]);
    var afChanged=Object.keys(final).filter(function(k){return final[k]&&JSON.stringify(svc[k])!==JSON.stringify(final[k])});
    afHistory.push({date:new Date().toISOString().slice(0,16).replace('T',' '),instruction:'AI自動入力',fileCount:(window._aiUploadedFiles||[]).length,changedFields:afChanged,user:currentUser||''});
    PS('ai_edit_history',afHistory);

    PS('service',merged);

    closeModal();
    const p=final;
    showModal('<div class="modal-header"><h3>✅ AI自動入力完了(高品質モード)</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body"><div style="font-size:13px;color:var(--text2);line-height:1.7">4ステップの深掘り分析でサービス概要を生成しました。<br><br><strong>入力された項目:</strong><br>'
      +(p.productName?'✅ 商材名: '+p.productName+'<br>':'')
      +(p.overview?'✅ 概要<br>':'')
      +(p.oneLineConcept?'✅ 一言コンセプト<br>':'')
      +(p.benefitList&&p.benefitList.filter(function(a){return a}).length?'✅ ベネフィット ('+p.benefitList.filter(function(a){return a}).length+'件)<br>':'')
      +(p.advantages&&p.advantages.filter(function(a){return a}).length?'✅ 競合優位性 ('+p.advantages.filter(function(a){return a}).length+'件)<br>':'')
      +(p.challengeHypothesis&&p.challengeHypothesis.filter(function(c){return c.challenge}).length?'✅ 課題仮説 ('+p.challengeHypothesis.filter(function(c){return c.challenge}).length+'件)<br>':'')
      +(p.preAdoptionConcerns&&(Array.isArray(p.preAdoptionConcerns)?p.preAdoptionConcerns.filter(function(x){return x}).length:p.preAdoptionConcerns)?'✅ 導入前の不安・障害<br>':'')
      +(p.personas&&p.personas.filter(function(x){return x.role}).length?'✅ ペルソナ ('+p.personas.filter(function(x){return x.role}).length+'件)<br>':'')
      +(p.competitors&&p.competitors.filter(function(c){return c.name}).length?'✅ 競合情報 ('+p.competitors.filter(function(c){return c.name}).length+'社)<br>':'')
      +(p.compAxes&&p.compAxes.filter(function(c){return c.axis}).length?'✅ 比較軸 ('+p.compAxes.filter(function(c){return c.axis}).length+'件)<br>':'')
      +(p.beforeAfter&&p.beforeAfter.filter(function(b){return b.type}).length?'✅ Before/After ('+p.beforeAfter.filter(function(b){return b.type}).length+'件)<br>':'')
      +(p.hearingItems&&p.hearingItems.filter(function(h){return h.question}).length?'✅ ヒアリング項目 ('+p.hearingItems.filter(function(h){return h.question}).length+'件)<br>':'')
      +(p.objectionHandling&&p.objectionHandling.filter(function(o){return o.objection}).length?'✅ 反論対策 ('+p.objectionHandling.filter(function(o){return o.objection}).length+'件)<br>':'')
      +(p.faq&&p.faq.filter(function(f){return f.q}).length?'✅ FAQ ('+p.faq.filter(function(f){return f.q}).length+'件)<br>':'')
      +(p.successCases&&p.successCases.filter(function(s){return s.company}).length?'✅ 成功事例 ('+p.successCases.filter(function(s){return s.company}).length+'件)<br>':'')
      +(p.ngWords&&p.ngWords.length?'✅ NGワード ('+p.ngWords.length+'件)<br>':'')
      +(p.appealWords?'✅ 訴求ワード<br>':'')
      +'<br>各項目はクリックして手動で修正できます。</div></div><div class="modal-footer"><button class="btn btn-primary" onclick="closeModal();renderService()">サービス概要を確認</button></div>',true);
  }catch(e){closeModal();alert('AI生成エラー: '+e.message)}
}

// === AI: Script Generation ===
function showAIScriptGen(){
  const svc=getSvc();
  if(!svc.productName&&!svc.overview)return alert('先にサービス概要を入力してください(手動またはAI自動入力)');
  showModal(`<div class="modal-header"><h3>🤖 AIスクリプト自動生成</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body">
  <div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7">
    サービス概要の情報をもとに、テレアポスクリプトを自動生成します。<br>
    ターゲットペルソナに最適化されたスクリプトが生成されます。
  </div>
  <div class="form-row"><label>スクリプトのトーン</label><select id="ai_sc_tone">
    <option value="professional">プロフェッショナル(丁寧・信頼感重視)</option>
    <option value="friendly">フレンドリー(親しみやすさ重視)</option>
    <option value="urgent">緊急性訴求(課題解決の緊急性を強調)</option>
    <option value="consultative">コンサルティング型(質問主体でヒアリング重視)</option>
  </select></div>
  <div class="form-row"><label>ターゲット</label><select id="ai_sc_target">
    <option value="決裁者">決裁者向け</option>
    <option value="窓口担当者">窓口担当者向け</option>
    <option value="現場担当者">現場担当者向け</option>
    <option value="受付">受付突破用</option>
  </select></div>
  <div class="form-row"><label>パターン数</label><select id="ai_sc_count"><option value="1">1パターン</option><option value="2" selected>2パターン</option><option value="3">3パターン</option></select></div>
  <div class="form-row"><label>追加指示(任意)</label><textarea id="ai_sc_extra" rows="2" placeholder="特に強調したいポイント、避けたい表現など"></textarea></div>
  </div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">キャンセル</button><button class="btn btn-primary" onclick="execAIScriptGen()">🤖 スクリプト生成</button></div>`,true);
}

async function execAIScriptGen(){
  const svc=getSvc();
  const tone=document.getElementById('ai_sc_tone').value;
  const target=document.getElementById('ai_sc_target').value;
  const count=parseInt(document.getElementById('ai_sc_count').value);
  const extra=document.getElementById('ai_sc_extra')?.value.trim()||'';
  showAILoading('スクリプトを生成中...');
  try{
    const toneMap={professional:'丁寧で信頼感のある言い回し。敬語を正確に使い、実績や数値で信頼性を担保する',friendly:'親しみやすく柔らかい言い回し。共感を示しつつ自然な会話の流れを作る',urgent:'課題の緊急性を強調。「今すぐ」「放置すると」等のフレーズで行動を促す',consultative:'質問主体のスクリプト。相手の状況をヒアリングしながら課題を顕在化させる'};
    const personas=svc.personas||[];
    const targetPersona=personas.find(p=>p.type===target)||{};

    const systemPrompt=`あなたはBtoB テレアポのスクリプト作成のプロフェッショナルです。以下の要件でスクリプトを作成してください。

## スクリプト作成の鉄則
1. **最初の15秒が勝負**: 受付突破と担当者の興味喚起に全力を注ぐ
2. **「売り込み」ではなく「情報提供」**: 相手にメリットのある情報を届ける姿勢
3. **数値・実績で信頼性**: 「多くの企業」ではなく「300社以上」のように具体的に
4. **相手の課題に寄り添う**: 自社の強みではなく、相手の課題から入る
5. **クロージングは選択肢**: 「いかがですか?」ではなく「〇日と△日どちらが〜」
6. **NGワードを絶対に使わない**: ${(svc.ngWords||[]).join('、')||'特になし'}

## トーン: ${toneMap[tone]}

## 構成
各スクリプトは以下の3パート構成:
- **挨拶・導入(15-20秒)**: 名乗り→用件の明確化→興味喚起の一言
- **本題・訴求(30-45秒)**: 課題提起→解決策の提示→実績/数値→オファー
- **クロージング(15-20秒)**: 日程調整→お礼→次のアクション確認

必ず以下のJSON配列で出力してください:
\`\`\`json
[
  {
    "name": "パターン名(例: 課題訴求型)",
    "intro": "挨拶・導入のトーク",
    "main": "本題・訴求のトーク",
    "closing": "クロージングのトーク",
    "notes": "このスクリプトの使い方・ポイント"
  }
]
\`\`\``;

    let userMsg=`【商材情報】\n商材名: ${svc.productName||'不明'}\n概要: ${svc.overview||'不明'}\n競合優位性: ${(svc.advantages||[]).filter(a=>a).join(' / ')||'不明'}\n料金: ${svc.unitPrice||'不明'}\nオファー: ${svc.offer||'不明'}\n導入メリット: ${svc.benefits||'不明'}\n\n`;
    userMsg+=`【ターゲット】${target}\n`;
    if(targetPersona.role)userMsg+=`役職: ${targetPersona.role}\n課題: ${targetPersona.issues||'不明'}\n関心事項: ${targetPersona.interest||'不明'}\n響くキーワード: ${targetPersona.keywords||'不明'}\n`;
    userMsg+=`\n${count}パターンのスクリプトを生成してください。\n`;
    if(extra)userMsg+=`\n【追加指示】${extra}\n`;

    const result=await callClaude(systemPrompt,userMsg,{maxTokens:5000});
    const jsonMatch=result.match(/```(?:json)?\s*([\s\S]*?)```/);
    if(!jsonMatch)throw new Error('AIの出力からJSONを抽出できませんでした');
    const scripts=JSON.parse(jsonMatch[1]);
    const existing=PD('scripts',[]);
    scripts.forEach(s=>existing.push(s));
    PS('scripts',existing);
    closeModal();
    alert(`${scripts.length}パターンのスクリプトを生成しました!`);
    renderScript();
  }catch(e){closeModal();alert('AI生成エラー: '+e.message)}
}

// === AI: Objection Handling Generation ===
function showAIObjGen(){
  const svc=getSvc();
  if(!svc.productName&&!svc.overview)return alert('先にサービス概要を入力してください');
  showAILoading('切り返し集を生成中...');
  execAIObjGen();
}

async function execAIObjGen(){
  const svc=getSvc();
  try{
    const systemPrompt=`あなたはBtoB テレアポの切り返しトークのプロフェッショナルです。

## 切り返しの鉄則
1. **否定しない**: 相手の言葉を一度受け止めてから切り返す(「おっしゃる通りです。実は〜」)
2. **質問で返す**: 直接的な反論ではなく、質問で相手の真意を引き出す
3. **共感→情報提供→質問**: この3ステップで構成する
4. **具体的な事例**: 「他社様でも同じ声がありましたが、実際には〜」
5. **押し売り厳禁**: 断りが固い場合は引き際を見極め、次回の種を撒く

以下のJSON配列形式で、BtoBテレアポでよくある断り文句に対する切り返しトークを生成してください。

\`\`\`json
[
  {"objection": "断り文句", "response": "切り返しトーク(3-5文。共感→情報提供→質問の流れ)"}
]
\`\`\`

各切り返しは自然な会話として成立するよう、口語体で書いてください。`;

    const userMsg=`【商材】${svc.productName||'不明'}\n【概要】${svc.overview||'不明'}\n【強み】${(svc.advantages||[]).filter(a=>a).join(' / ')||'不明'}\n【料金】${svc.unitPrice||'不明'}\n【NGワード】${(svc.ngWords||[]).join('、')||'特になし'}\n\n以下の断り文句に対する切り返しを各2-3パターン生成してください:\n1. 「今は間に合っています」\n2. 「忙しいので結構です」\n3. 「予算がありません」\n4. 「他社を使っています」\n5. 「資料だけ送ってください」\n6. 「上に確認しないと」\n7. 「検討します」\n8. 「営業は受け付けていません」\n9. 「うちには必要ありません」\n10. 「以前使ったけどダメだった」`;

    const result=await callClaude(systemPrompt,userMsg,{maxTokens:5000});
    const jsonMatch=result.match(/```(?:json)?\s*([\s\S]*?)```/);
    if(!jsonMatch)throw new Error('AIの出力からJSONを抽出できませんでした');
    const objs=JSON.parse(jsonMatch[1]);
    const existing=PD('objections',[]);
    objs.forEach(o=>existing.push(o));
    PS('objections',existing);
    closeModal();
    alert(`${objs.length}件の切り返しトークを生成しました!`);
    renderObjection();
  }catch(e){closeModal();alert('AI生成エラー: '+e.message)}
}

// === AI: LinkedIn Profile/Offer/Header Design ===
function showAILinkedInGen(){
  const svc=getSvc();
  showModal(`<div class="modal-header"><h3>🤖 AI LinkedIn文面生成</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body">
  <div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7">
    サービス概要に基づき、LinkedInのプロフィール設計・オファー文面・ヘッダー提案を生成します。
  </div>
  <div class="form-row"><label>生成タイプ</label><select id="ai_li_type">
    <option value="profile">プロフィール設計(ヘッドライン/要約/経歴)</option>
    <option value="offer">オファー文面(初回DM/フォローアップ)</option>
    <option value="header">ヘッダー画像テキスト提案</option>
    <option value="all">全て生成</option>
  </select></div>
  <div class="form-row"><label>アカウントの立場</label><select id="ai_li_role">
    <option value="PM">PM(プロジェクトマネージャー)</option>
    <option value="コンサルタント">コンサルタント</option>
    <option value="セールス">セールス担当</option>
    <option value="経営者">経営者/取締役</option>
  </select></div>
  <div class="form-row"><label>追加指示(任意)</label><textarea id="ai_li_extra" rows="2" placeholder="特定の業界に絞りたい、カジュアルなトーンで等"></textarea></div>
  </div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">キャンセル</button><button class="btn btn-primary" onclick="execAILinkedInGen()">🤖 生成</button></div>`,true);
}

async function execAILinkedInGen(){
  const svc=getSvc();
  const type=document.getElementById('ai_li_type').value;
  const role=document.getElementById('ai_li_role').value;
  const extra=document.getElementById('ai_li_extra')?.value.trim()||'';
  showAILoading('LinkedIn文面を生成中...');
  try{
    const systemPrompt=`あなたはLinkedIn営業のプロフェッショナルです。BtoB営業でLinkedInを活用したリード獲得に精通しています。

## LinkedIn営業の鉄則
1. **売り込まない**: 価値提供ファーストの姿勢。まず相手に有益な情報を届ける
2. **パーソナライズ**: 「テンプレ感」を消す。相手の投稿や経歴に言及する余地を残す
3. **短く簡潔に**: DMは150文字以内が理想。長いと読まれない
4. **CTA(行動喚起)は1つ**: 「資料送っていいですか?」か「15分お話しませんか?」のどちらか
5. **プロフィールは信頼の証明**: 実績数値、専門分野、提供価値を明確にする

生成結果はJSON形式で出力してください:
\`\`\`json
{
  "profile": {
    "headline": "LinkedIn ヘッドラインテキスト(120文字以内)",
    "summary": "プロフィール要約(自己紹介文。5-8文)",
    "experience": "経歴の書き方提案",
    "skills": ["スキル1", "スキル2", "スキル3"]
  },
  "offers": [
    {"name": "初回申請メッセージ", "type": "初回文面", "body": "テンプレート文面({会社名}{名前}等の変数可)"},
    {"name": "承認後初回DM", "type": "フォローアップ", "body": ""},
    {"name": "未返信フォローアップ", "type": "フォローアップ", "body": ""},
    {"name": "実績訴求DM", "type": "実績文面", "body": ""}
  ],
  "header": {
    "mainText": "ヘッダー画像のメインコピー",
    "subText": "サブコピー",
    "keywords": ["キーワード1", "キーワード2"],
    "colorScheme": "推奨カラースキーム",
    "layout": "レイアウト提案"
  }
}
\`\`\`
要求されたタイプのみ中身を充実させ、不要なタイプは最小限でOKです。`;

    let userMsg=`【商材】${svc.productName||'不明'}\n【概要】${svc.overview||'不明'}\n【強み】${(svc.advantages||[]).filter(a=>a).join(' / ')||'不明'}\n【ターゲット業界】${svc.targetIndustry||'不明'}\n【ターゲット役職】${svc.targetRole||'不明'}\n\n【アカウントの立場】${role}\n【生成タイプ】${type==='all'?'プロフィール・オファー文面・ヘッダー全て':type==='profile'?'プロフィール設計のみ':type==='offer'?'オファー文面のみ':'ヘッダーテキスト提案のみ'}\n`;
    if(extra)userMsg+=`\n【追加指示】${extra}\n`;

    const result=await callClaude(systemPrompt,userMsg,{maxTokens:4000});
    const jsonMatch=result.match(/```(?:json)?\s*([\s\S]*?)```/);
    if(!jsonMatch)throw new Error('AIの出力からJSONを抽出できませんでした');
    const data=JSON.parse(jsonMatch[1]);

    // Save offer templates to linkedin_templates
    if(data.offers&&data.offers.length){
      const existing=PD('linkedin_templates',[]);
      data.offers.filter(o=>o.body).forEach(o=>existing.push({name:o.name,type:o.type,body:o.body}));
      PS('linkedin_templates',existing);
    }

    // Show results in modal
    closeModal();
    let resultHTML='<div style="font-size:13px;line-height:1.8;color:var(--text2)">';
    if(data.profile&&(type==='profile'||type==='all')){
      resultHTML+=`<div style="margin-bottom:16px"><div style="font-weight:700;color:var(--text);margin-bottom:8px">📋 プロフィール設計</div>
      <div style="background:var(--bg3);padding:12px;border-radius:8px;margin-bottom:8px"><strong>ヘッドライン:</strong><br>${data.profile.headline||'-'}</div>
      <div style="background:var(--bg3);padding:12px;border-radius:8px;margin-bottom:8px"><strong>要約:</strong><br><div style="white-space:pre-wrap">${data.profile.summary||'-'}</div></div>
      ${data.profile.experience?`<div style="background:var(--bg3);padding:12px;border-radius:8px;margin-bottom:8px"><strong>経歴:</strong><br>${data.profile.experience}</div>`:''}
      </div>`;
    }
    if(data.offers&&data.offers.filter(o=>o.body).length&&(type==='offer'||type==='all')){
      resultHTML+=`<div style="margin-bottom:16px"><div style="font-weight:700;color:var(--text);margin-bottom:8px">💬 オファー文面 (${data.offers.filter(o=>o.body).length}件 → テンプレに保存済み)</div>
      ${data.offers.filter(o=>o.body).map(o=>`<div style="background:var(--bg3);padding:12px;border-radius:8px;margin-bottom:8px"><strong>${o.name}</strong><br><div style="white-space:pre-wrap;margin-top:4px">${o.body}</div></div>`).join('')}</div>`;
    }
    if(data.header&&(type==='header'||type==='all')){
      resultHTML+=`<div style="margin-bottom:16px"><div style="font-weight:700;color:var(--text);margin-bottom:8px">🎨 ヘッダー提案</div>
      <div style="background:var(--bg3);padding:12px;border-radius:8px">
      <strong>メインコピー:</strong> ${data.header.mainText||'-'}<br>
      <strong>サブコピー:</strong> ${data.header.subText||'-'}<br>
      <strong>カラー:</strong> ${data.header.colorScheme||'-'}<br>
      <strong>レイアウト:</strong> ${data.header.layout||'-'}
      </div></div>`;
    }
    resultHTML+='</div>';

    showModal(`<div class="modal-header"><h3>✅ LinkedIn文面生成完了</h3><button class="modal-close" onclick="closeModal()">✕</button></div><div class="modal-body" style="max-height:70vh;overflow-y:auto">${resultHTML}</div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">閉じる</button><button class="btn btn-primary" onclick="closeModal();navigateTo('linkedin_template')">テンプレートを確認</button></div>`,true);
  }catch(e){closeModal();alert('AI生成エラー: '+e.message)}
}

// === AI: FAQ Auto Generation ===
async function execAIFAQGen(){
  const svc=getSvc();
  if(!svc.productName&&!svc.overview)return alert('先にサービス概要を入力してください');
  showAILoading('FAQを生成中...');
  try{
    const systemPrompt=`あなたはBtoB営業のFAQ作成のプロです。テレアポ・インサイドセールスの現場で実際に聞かれる質問と、営業が即答できる回答を作成してください。

## FAQ作成の鉄則
1. **実際に聞かれる質問**: 理想的な質問ではなく、現場で本当に出る質問
2. **回答は簡潔に**: 30秒以内で口頭で説明できる長さ
3. **数値を含める**: 可能な限り具体的な数値で回答する
4. **自信を持って答える**: 曖昧な表現を避け、断言できる形にする
5. **カテゴリ分け**: 料金/機能/導入/サポート/競合比較で網羅する

JSON配列で出力:
\`\`\`json
[{"q": "質問", "a": "回答"}]
\`\`\``;

    const userMsg=`【商材】${svc.productName||'不明'}\n【概要】${svc.overview||'不明'}\n【料金】${svc.unitPrice||'不明'}\n【強み】${(svc.advantages||[]).filter(a=>a).join('、')||'不明'}\n【弱み】${(svc.weaknesses||[]).filter(w=>w).join('、')||'不明'}\n\nテレアポで頻出する15個のFAQを生成してください。料金系、機能系、導入系、競合比較系、サポート系を網羅的に。`;

    const result=await callClaude(systemPrompt,userMsg,{maxTokens:3000});
    const jsonMatch=result.match(/```(?:json)?\s*([\s\S]*?)```/);
    if(!jsonMatch)throw new Error('JSONを抽出できませんでした');
    const faqs=JSON.parse(jsonMatch[1]);
    const svcData=getSvc();
    svcData.faq=[...(svcData.faq||[]),...faqs];
    PS('service',svcData);
    closeModal();
    alert(`${faqs.length}件のFAQを生成しました!`);
    window._svcTab='knowledge';renderService();
  }catch(e){closeModal();alert('AI生成エラー: '+e.message)}
}

// === AI: Mail Template Generation ===
async function execAIMailGen(){
  const svc=getSvc();
  if(!svc.productName&&!svc.overview)return alert('先にサービス概要を入力してください');
  showAILoading('メールテンプレートを生成中...');
  try{
    const systemPrompt=`あなたはBtoB営業メールのプロです。以下のJSON配列で、営業メールのテンプレートを生成してください。

## メール作成の鉄則
1. **件名が命**: 開封率を最大化する件名。15文字以内が理想
2. **最初の2行で用件**: スクロールせずに要点が分かるように
3. **CTA(行動喚起)は1つだけ**: 複数の選択肢は迷わせる
4. **パーソナライズの余地**: {会社名}{名前}等の変数を使用
5. **3段落以内**: 長いメールは読まれない

\`\`\`json
[{"name": "テンプレート名", "subject": "件名", "body": "本文({会社名}{担当者名}等の変数利用可)"}]
\`\`\``;

    const userMsg=`【商材】${svc.productName||'不明'}\n【概要】${svc.overview||'不明'}\n\n以下4種類のメールテンプレートを生成:\n1. アポ獲得後のお礼メール\n2. 資料送付メール\n3. 商談後フォローアップメール\n4. 休眠リード掘り起こしメール`;

    const result=await callClaude(systemPrompt,userMsg,{maxTokens:3000});
    const jsonMatch=result.match(/```(?:json)?\s*([\s\S]*?)```/);
    if(!jsonMatch)throw new Error('JSONを抽出できませんでした');
    const mails=JSON.parse(jsonMatch[1]);
    const existing=PD('mail_templates',[]);
    mails.forEach(m=>existing.push(m));
    PS('mail_templates',existing);
    closeModal();
    alert(`${mails.length}件のメールテンプレートを生成しました!`);
    navigateTo('mail');
  }catch(e){closeModal();alert('AI生成エラー: '+e.message)}
}

// ============================================================================
// Change Log (変更履歴) - D1版(2026-05-04 v9・Supabase change_history 廃止)
// データソース:
//   - localStorage `sb__undo_history`: S() のたび core.js が積む変更前値(最大100件・user/key/page/project)
//   - localStorage `audit_log`: auditLog() で明示記録(最大1000件・action/table/details)
// 統合表示: 両者を時系列マージ・タイプバッジで区別・undo_history は復元ボタン付き
// ============================================================================
let _clPage=0;
const _clPageSize=50;
const _CL_KEY_LABELS={
  'projects':'案件一覧','members':'メンバー','service':'サービス概要','call_list':'架電リスト',
  'appointments':'アポイント','daily_teleapo':'日次テレアポ','onboarding':'オンボーディング',
  'linkedin_requests':'LinkedIn申請','linkedin_approved':'LinkedIn承認','linkedin_templates':'LIテンプレ',
  'daily_linkedin':'日次LinkedIn','scripts':'スクリプト','objections':'切り返し',
  'mail_templates':'メールテンプレ','ng_list':'NGリスト','targets':'目標値',
  'report_history':'レポート履歴','apt_meta':'アポ詳細','ai_sources':'AI入力ソース',
  'weekly_mtg':'週次MTG','knowledge_hub':'ナレッジ集','call_change_log':'架電リスト変更ログ',
  'list_requests':'リスト依頼','contracts':'契約情報','contract':'契約情報','tasks':'タスク',
  'announcements':'お知らせ','reminders':'リマインダー','minutes':'議事録','dashboard_widgets':'ダッシュ',
  'kickoff':'キックオフ','linkedin_request':'LinkedIn依頼','call_list_v3':'架電リストv3',
  'saved_filters':'保存フィルタ','slack_config':'Slack設定','custom_columns':'カスタム列'
};
function _clKeyLabel(k){
  if(!k)return '-';
  // proj_<id>_<key> 形式は親キーを抽出
  const m=String(k).match(/^proj_(.+?)_(.+)$/);
  const base=m?m[2]:k;
  return _CL_KEY_LABELS[base]||base;
}

// 2系統データを統合して時系列降順で返す
function _collectChangeLog(){
  const items=[];
  const seenIds=new Set();
  // Undo履歴(変更前値あり・自分のブラウザ操作分のみ・復元可能)
  try{
    const undo=JSON.parse(localStorage.getItem('sb__undo_history')||'[]');
    undo.forEach(function(u){
      items.push({
        type:'edit',source:'undo',id:u.id,ts:u.ts,user:u.user||'anon',
        key:u.key,action:'更新',hasRestore:true,prev:u.prev,
        page:u.page||'',project:u.project||''
      });
      seenIds.add(u.id);
    });
  }catch(e){}
  // 2026-05-04 v9.11: グローバル監査ログ(D1 経由で全員のブラウザから見える)
  try{
    const globals=(typeof getGlobalAuditLogs==='function')?getGlobalAuditLogs():[];
    globals.forEach(function(a){
      if(!a||!a.id||seenIds.has(a.id))return;
      items.push({
        type:'audit',source:'global',id:a.id,ts:a.ts,user:a.user||'-',
        key:a.table||'',action:a.action||'操作',hasRestore:false,details:a.details||'',
        project:a.project||'',
        isRemote:!a.user||a.user!==(typeof currentUser!=='undefined'?currentUser:'')
      });
      seenIds.add(a.id);
    });
  }catch(e){}
  // ローカル監査ログ(auditLog 呼出・自分のブラウザ)
  try{
    const audit=JSON.parse(localStorage.getItem('audit_log')||'[]');
    audit.forEach(function(a){
      if(!a||!a.id||seenIds.has(a.id))return;
      items.push({
        type:'audit',source:'audit',id:a.id,ts:a.ts,user:a.user||'-',
        key:a.table||'',action:a.action||'操作',hasRestore:false,details:a.details||'',
        project:a.project||''
      });
      seenIds.add(a.id);
    });
  }catch(e){}
  // 時系列降順
  items.sort(function(a,b){return(b.ts||'').localeCompare(a.ts||'')});
  return items;
}

function renderChangeLog(){
  const mc=document.getElementById('mainContent');
  const all=_collectChangeLog();
  // フィルタ用候補
  const keys=[...new Set(all.map(function(x){return x.key}).filter(Boolean))].sort();
  const users=[...new Set(all.map(function(x){return x.user}).filter(Boolean))].sort();
  mc.innerHTML='<div class="page-header"><div class="page-title-area"><div class="page-title">変更履歴</div><div class="page-desc">誰が・いつ・何を変更したかの記録。Undo履歴(直近100件)+ 監査ログ(直近1000件)統合表示。任意の時点にデータを復元できます。</div></div></div>'
    +'<div class="card" style="margin-bottom:14px"><div class="card-body" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">'
      +'<select id="clKeyFilter" onchange="_clPage=0;_renderChangeLogList()" style="padding:8px 12px;background:var(--bg3);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-size:12px"><option value="">全てのデータ</option>'
        +keys.map(function(k){return '<option value="'+esc(k)+'">'+esc(_clKeyLabel(k))+(k!==_clKeyLabel(k)?' ('+esc(k)+')':'')+'</option>'}).join('')
      +'</select>'
      +'<select id="clUserFilter" onchange="_clPage=0;_renderChangeLogList()" style="padding:8px 12px;background:var(--bg3);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-size:12px"><option value="">全ユーザー</option>'
        +users.map(function(u){return '<option value="'+esc(u)+'">'+esc(u)+'</option>'}).join('')
      +'</select>'
      +'<select id="clTypeFilter" onchange="_clPage=0;_renderChangeLogList()" style="padding:8px 12px;background:var(--bg3);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-size:12px">'
        +'<option value="">全種別</option>'
        +'<option value="edit">編集(復元可能)</option>'
        +'<option value="audit">監査ログ</option>'
      +'</select>'
      +'<input type="date" id="clDateFrom" onchange="_clPage=0;_renderChangeLogList()" style="padding:8px 12px;background:var(--bg3);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-size:12px">'
      +'<button class="btn btn-ghost btn-sm" onclick="_renderChangeLogList()" style="font-size:11px">🔄 更新</button>'
      +(isPM&&typeof isPM==='function'&&isPM()?'<button class="btn btn-ghost btn-sm" onclick="if(confirm(\'Undo履歴をクリアしますか?\')){clearUndoHistory();_renderChangeLogList()}" style="font-size:11px;color:var(--red)">Undo履歴クリア</button>':'')
      +'<div style="margin-left:auto;font-size:11px;color:var(--text3)" id="clCount"></div>'
    +'</div></div>'
    +'<div id="clList"></div>'
    +'<div id="clPager" style="display:flex;gap:8px;justify-content:center;margin-top:14px"></div>';
  _renderChangeLogList();
}

// 2026-05-04 v9.10: 変更内容サマリ生成(一覧で「何が変わったか」を一目で見せる)
function _summarizeChange(prev,key){
  try{
    let cur=null;
    try{cur=PD(key,null)}catch(e){}
    // null扱い判定
    const isEmpty=v=>v===null||v===undefined;
    if(isEmpty(prev)&&isEmpty(cur))return '<span style="color:var(--text3)">—</span>';
    if(isEmpty(prev)&&!isEmpty(cur)){
      if(Array.isArray(cur))return '<span style="color:var(--green);font-weight:600">+新規作成('+cur.length+'件)</span>';
      if(typeof cur==='object')return '<span style="color:var(--green);font-weight:600">+新規作成('+Object.keys(cur).length+'フィールド)</span>';
      return '<span style="color:var(--green);font-weight:600">+新規作成</span>';
    }
    if(!isEmpty(prev)&&isEmpty(cur))return '<span style="color:var(--red);font-weight:600">削除</span>';
    // 配列差分
    if(Array.isArray(prev)&&Array.isArray(cur)){
      const dn=cur.length-prev.length;
      const sign=dn>0?'+':'';
      const col=dn>0?'var(--green)':dn<0?'var(--red)':'var(--text3)';
      return '<span style="color:'+col+'">'+prev.length+' → '+cur.length+'件 ('+sign+dn+')</span>';
    }
    // オブジェクト差分(最大2フィールド)
    if(typeof prev==='object'&&typeof cur==='object'&&prev&&cur){
      const allKeys=new Set([...Object.keys(prev),...Object.keys(cur)]);
      const changed=[];
      for(const k of allKeys){
        const a=prev[k],b=cur[k];
        if(JSON.stringify(a)!==JSON.stringify(b))changed.push(k);
        if(changed.length>=3)break;
      }
      if(!changed.length)return '<span style="color:var(--text3)">差分なし</span>';
      const head=changed[0];
      const a=prev[head],b=cur[head];
      const aStr=(a===undefined||a===null||a==='')?'(空)':typeof a==='string'?'"'+a.slice(0,30)+(a.length>30?'…':'')+'"':JSON.stringify(a).slice(0,40);
      const bStr=(b===undefined||b===null||b==='')?'(空)':typeof b==='string'?'"'+b.slice(0,30)+(b.length>30?'…':'')+'"':JSON.stringify(b).slice(0,40);
      const more=changed.length>1?' 他'+(changed.length-1)+'件':'';
      return '<code style="font-size:10px;color:var(--blue)">'+esc(head)+'</code>: <span style="color:var(--text2)">'+esc(aStr)+'</span> → <span style="color:var(--text);font-weight:600">'+esc(bStr)+'</span>'+(more?'<span style="color:var(--text3);font-size:10px">'+more+'</span>':'');
    }
    // 文字列・数値
    const aS=typeof prev==='string'?'"'+prev.slice(0,30)+(prev.length>30?'…':'')+'"':String(prev);
    const bS=typeof cur==='string'?'"'+cur.slice(0,30)+(cur.length>30?'…':'')+'"':String(cur);
    return '<span style="color:var(--text2)">'+esc(aS)+'</span> → <span style="color:var(--text);font-weight:600">'+esc(bS)+'</span>';
  }catch(e){return '<span style="color:var(--text3)">差分取得失敗</span>'}
}

function _renderChangeLogList(){
  const all=_collectChangeLog();
  const keyF=document.getElementById('clKeyFilter')?.value||'';
  const userF=document.getElementById('clUserFilter')?.value||'';
  const typeF=document.getElementById('clTypeFilter')?.value||'';
  const dateF=document.getElementById('clDateFrom')?.value||'';
  const filtered=all.filter(function(r){
    if(keyF&&r.key!==keyF)return false;
    if(userF&&r.user!==userF)return false;
    if(typeF&&r.type!==typeF)return false;
    if(dateF&&r.ts<dateF+'T00:00:00')return false;
    return true;
  });
  const total=filtered.length;
  const cnt=document.getElementById('clCount');
  if(cnt)cnt.textContent=total+'件の履歴';
  const list=document.getElementById('clList');
  if(!list)return;
  if(!total){
    list.innerHTML='<div class="card"><div class="card-body" style="text-align:center;padding:40px;color:var(--text3);font-size:12px">📋 該当する履歴がありません<br><span style="font-size:11px;opacity:.7">※ データを編集・操作すると自動で記録されます</span></div></div>';
    document.getElementById('clPager').innerHTML='';
    return;
  }
  // ページング
  const start=_clPage*_clPageSize;
  const page=filtered.slice(start,start+_clPageSize);
  list.innerHTML=page.map(function(r){
    const dt=new Date(r.ts);
    const timeStr=isNaN(dt)?r.ts:(dt.getMonth()+1)+'/'+dt.getDate()+' '+dt.getHours()+':'+String(dt.getMinutes()).padStart(2,'0');
    const actionMap={'create':['作成','var(--green)'],'update':['更新','var(--blue)'],'delete':['削除','var(--red)'],'restore':['復元','var(--purple)'],'undo':['取消','var(--orange)'],'import':['取込','var(--cyan)'],'purge_all':['全削除','var(--red)'],'add':['追加','var(--green)'],'migrate':['マイグレ','var(--purple)']};
    const actMeta=actionMap[r.action]||[r.action,'var(--blue)'];
    const typeBadge=r.type==='audit'?'<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:var(--bg3);color:var(--text3);margin-right:4px">監査</span>':'<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(59,130,246,.15);color:var(--blue);margin-right:4px">編集</span>';
    const projTag=r.project?'<span style="font-size:10px;color:var(--text3);background:var(--bg3);padding:1px 6px;border-radius:4px">'+esc(r.project.slice(0,8))+'</span>':'';
    // 変更内容サマリ(v9.10: 一覧で何が変わったか可視化)
    const changeSummary=r.type==='edit'?_summarizeChange(r.prev,r.key):'';
    return '<div class="card" style="margin-bottom:6px"><div class="card-body" style="padding:12px 14px;display:flex;flex-direction:column;gap:6px">'
      +'<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">'
        +typeBadge
        +'<span style="font-size:11px;color:var(--text3);min-width:90px;font-variant-numeric:tabular-nums">'+timeStr+'</span>'
        +'<span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;background:'+actMeta[1]+'22;color:'+actMeta[1]+'">'+actMeta[0]+'</span>'
        +'<span style="font-size:13px;font-weight:700;color:var(--text)">'+esc(_clKeyLabel(r.key))+'</span>'
        +(r.key&&r.key!==_clKeyLabel(r.key)?'<span style="font-size:10px;color:var(--text3);font-family:monospace;opacity:.7">'+esc(r.key)+'</span>':'')
        +'<span style="font-size:11px;color:var(--text2);font-weight:600">👤 '+esc(r.user)+'</span>'
        +projTag
        +(r.hasRestore?'<button onclick="_showChangeLogRestore(\''+r.id+'\')" class="btn btn-ghost" style="margin-left:auto;font-size:11px;padding:4px 10px;border:1px solid var(--blue);color:var(--blue)">↩ 復元</button>':'<span style="margin-left:auto"></span>')
      +'</div>'
      +(changeSummary?'<div style="font-size:11px;line-height:1.6;padding:6px 10px;background:var(--bg);border-radius:6px;border-left:3px solid '+actMeta[1]+'">'+changeSummary+'</div>':'')
      +(r.details?'<div style="font-size:11px;color:var(--text2);padding:4px 10px">📝 '+esc(r.details)+'</div>':'')
    +'</div></div>';
  }).join('');
  // ページャ
  const totalPages=Math.ceil(total/_clPageSize);
  const pager=document.getElementById('clPager');
  if(pager){
    if(totalPages>1){
      pager.innerHTML=Array.from({length:Math.min(totalPages,10)},function(_,i){return '<button class="btn '+(i===_clPage?'btn-primary':'btn-ghost')+'" style="font-size:11px;padding:4px 10px" onclick="_clPage='+i+';_renderChangeLogList()">'+(i+1)+'</button>'}).join('');
    }else{pager.innerHTML=''}
  }
}

function _showChangeLogRestore(undoId){
  const undo=(function(){try{return JSON.parse(localStorage.getItem('sb__undo_history')||'[]')}catch(e){return[]}})();
  const entry=undo.find(function(u){return u.id===undoId});
  if(!entry)return alert('履歴が見つかりません(ローカルストレージから消えた可能性)');
  const dt=new Date(entry.ts);
  const timeStr=dt.getFullYear()+'/'+(dt.getMonth()+1)+'/'+dt.getDate()+' '+dt.getHours()+':'+String(dt.getMinutes()).padStart(2,'0');
  const prevJson=entry.prev?JSON.stringify(entry.prev,null,2):'(空・このキーが存在しなかった状態)';
  const curJson=(function(){try{const cur=PD(entry.key,null);return cur?JSON.stringify(cur,null,2):'(空)'}catch(e){return '(取得失敗)'}})();
  showModal('<div class="modal-header"><h3>🔄 データ復元</h3><button class="modal-close" onclick="closeModal()">✕</button></div>'
    +'<div class="modal-body" style="max-height:60vh;overflow-y:auto">'
      +'<div style="font-size:12px;color:var(--text2);margin-bottom:14px;line-height:1.7;background:var(--bg3);padding:12px 16px;border-radius:8px">'
        +'<b>日時:</b> '+timeStr+'<br>'
        +'<b>操作者:</b> '+esc(entry.user||'-')+'<br>'
        +'<b>データキー:</b> <code style="background:var(--bg);padding:2px 6px;border-radius:4px">'+esc(entry.key)+'</code> ('+esc(_clKeyLabel(entry.key))+')<br>'
        +(entry.page?'<b>編集ページ:</b> '+esc(entry.page)+'<br>':'')
        +(entry.project?'<b>案件:</b> '+esc(entry.project)+'<br>':'')
      +'</div>'
      +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">'
        +'<div><div style="font-size:11px;font-weight:700;color:var(--green);margin-bottom:6px">↩ 変更前(この状態に戻ります)</div>'
          +'<pre style="background:var(--bg);padding:10px;border-radius:8px;font-size:10px;max-height:300px;overflow:auto;border:1px solid var(--green);color:var(--text2);white-space:pre-wrap;word-break:break-all">'+esc(prevJson.slice(0,5000))+(prevJson.length>5000?'\n... (省略)':'')+'</pre></div>'
        +'<div><div style="font-size:11px;font-weight:700;color:var(--text3);margin-bottom:6px">📍 変更後(現在)</div>'
          +'<pre style="background:var(--bg);padding:10px;border-radius:8px;font-size:10px;max-height:300px;overflow:auto;border:1px solid var(--border);color:var(--text2);white-space:pre-wrap;word-break:break-all">'+esc(curJson.slice(0,5000))+(curJson.length>5000?'\n... (省略)':'')+'</pre></div>'
      +'</div>'
    +'</div>'
    +'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">キャンセル</button>'
      +'<button class="btn btn-primary" onclick="_execChangeLogRestore(\''+undoId+'\')">↩ この状態に復元する</button>'
    +'</div>',true);
}

function _execChangeLogRestore(undoId){
  if(!confirm('本当にこの時点のデータに復元しますか?\n\n現在のデータは上書きされます。\n(操作はさらに新しいUndo履歴として記録されるため、再度元に戻すこともできます)'))return;
  const ok=undoOne(undoId);
  if(ok){
    closeModal();
    if(typeof toast==='function')toast('復元しました');
    // 変更履歴ページを再描画
    setTimeout(function(){if(typeof renderChangeLog==='function')renderChangeLog()},100);
  }else{
    alert('復元に失敗しました(履歴が見つからない可能性)');
  }
}

// 旧API互換(外部から showVersionRestore が呼ばれた場合のフォールバック)
function showVersionRestore(historyId){_showChangeLogRestore(historyId)}
function execRestore(storeKey,historyId){_execChangeLogRestore(historyId)}
function loadChangeLog(){_renderChangeLogList()}
function loadChangeLogFilters(){/* D1版では不要(renderChangeLog 内でフィルタ生成)*/}

// === AI Settings UI ===
function renderAISettings(){
  return`<div class="card" style="margin-bottom:14px"><div class="card-header"><span class="card-title">🤖 AI設定</span></div><div class="card-body">
    <div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">
      AI機能はサーバー経由で動作します。APIキーの設定は不要です。<br>
      サービス概要・スクリプト・切り返し集・LinkedIn文面・FAQ・メールテンプレートをAIで自動生成できます。
    </div>
    <div style="font-size:11px;color:var(--text3)">ステータス: <span style="color:var(--green)">✅ 有効</span> | モデル: <span style="color:var(--blue);font-weight:700">Claude Opus 4.7</span> <span style="color:var(--text3)">(claude-opus-4-7 / 1M context・最新)</span></div>
  </div></div>
  <div class="card" style="margin-bottom:14px"><div class="card-header"><span class="card-title">☁️ データベース接続</span></div><div class="card-body">
    <div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">
      Supabase (PostgreSQL) でデータを永続化・リアルタイム同期しています。<br>
      全端末からアクセス可能。変更履歴の記録・バージョン復元もサポート。
    </div>
    <div style="font-size:11px;color:var(--text3)">接続: <span style="color:${_sb?'var(--green)':'var(--red)'}"> ${_sb?'✅ 接続中':'❌ 未接続'}</span> | 同期: ${_sbReady?'完了':'未完了'}</div>
  </div></div>`;
}

:LiFolder: ソースファイルのパス

/Users/oogushiyuuki/株式会社SCALE/scale-lead/ai.js

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加