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,'"')}" 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: 注意事項
- 依存パッケージを忘れず追加