/* AAU CRM — Hệ thống: 7 màn quản trị port từ backend CQA gốc (Nguồn kết nối · AI Cost · Nhật ký hoạt động · MCP · Nhật ký thông báo · Agents · Dữ liệu mẫu) Mỗi màn map thẳng vào endpoint backend gốc — xem caption dưới tiêu đề. */ const FX_RATE = 25400; // USD→VND, đồng bộ với System Settings › Tỷ giá function nowMinus(min) { return new Date(Date.now() - min * 60000).toISOString(); } function fmtTime(iso) { const d = new Date(iso); const p = n => String(n).padStart(2, '0'); return p(d.getDate()) + '/' + p(d.getMonth() + 1) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); } /* ───────────────────────── 1 · NGUỒN KẾT NỐI (Channels) ───────────────────────── */ const SYNC_CHANNELS = [ { id: 'ch_fb', ch: 'fb', name: 'AAU Academy', handle: 'fb.com/aau.academy', status: 'connected', convs: 3420, lastSync: nowMinus(5), tokenDays: 58, mode: 'Mỗi 15 phút' }, { id: 'ch_zalo', ch: 'zalo', name: 'AAU Academy OA', handle: 'OA · 400089...', status: 'connected', convs: 1862, lastSync: nowMinus(12), tokenDays: 21, mode: 'Sau mỗi lần đồng bộ' }, { id: 'ch_ig', ch: 'instagram', name: 'aau.academy', handle: 'ig/aau.academy', status: 'error', convs: 740, lastSync: nowMinus(2880), tokenDays: -1, mode: 'Mỗi 30 phút' }, { id: 'ch_tiktok', ch: 'tiktok', name: 'TikTok @aauacademy', handle: 'chưa hỗ trợ ingest', status: 'disconnected', convs: 0, lastSync: null, tokenDays: null, mode: '—' }, ]; const SYNC_HISTORY = [ { t: nowMinus(5), kind: 'Tự động', added: 34, status: 'ok', dur: '4,2s' }, { t: nowMinus(20), kind: 'Sau đồng bộ', added: 12, status: 'ok', dur: '2,8s' }, { t: nowMinus(35), kind: 'Thủ công', added: 58, status: 'ok', dur: '6,1s' }, { t: nowMinus(95), kind: 'Tự động', added: 0, status: 'error', dur: '30s (timeout)' }, { t: nowMinus(140), kind: 'Tự động', added: 21, status: 'ok', dur: '3,5s' }, ]; const CH_STATUS = { connected: { tone: 'success', label: 'Đã kết nối', dot: true }, error: { tone: 'critical', label: 'Lỗi token' }, disconnected: { tone: 'neutral', label: 'Chưa kết nối' } }; // Form kết nối / sửa kênh — token thật do người dùng cấp; backend chỉ đánh dấu hasToken. // Có `channel` ⇒ chế độ SỬA (prefill + PUT); không có ⇒ tạo mới (POST). function ConnectChannelModal({ channel, onClose, onSubmit }) { const editing = !!channel; const [ch, setCh] = useState(channel ? channel.ch : 'zalo'); const [name, setName] = useState(channel ? channel.name || '' : ''); const [handle, setHandle] = useState(channel ? channel.handle || '' : ''); const [mode, setMode] = useState(channel ? channel.mode || 'Mỗi 15 phút' : 'Mỗi 15 phút'); const [token, setToken] = useState(''); const [busy, setBusy] = useState(false); const chOpts = [ { value: 'zalo', label: 'Zalo OA' }, { value: 'fb', label: 'Facebook Messenger' }, { value: 'instagram', label: 'Instagram' }, { value: 'tiktok', label: 'TikTok' }, ]; const modeOpts = ['Mỗi 15 phút', 'Mỗi 30 phút', 'Mỗi giờ', 'Sau mỗi lần đồng bộ', 'Thủ công'].map(m => ({ value: m, label: m })); const submit = async () => { if (!name.trim() || busy) return; setBusy(true); try { await onSubmit({ ch, name: name.trim(), handle: handle.trim(), mode, token: token.trim() }); onClose(); } finally { setBusy(false); } }; return ( }>
Token thật của Zalo/FB do bạn cấp. App chỉ đánh dấu đã có token (production chuyển cho CQA / secret store), không lưu chuỗi token trong DB.
); } function ChatChannels() { const live = !!(window.API && window.API.enabled); const [sel, setSel] = useState('ch_fb'); const [list, setList] = useState(() => SYNC_CHANNELS.map(c => ({ ...c }))); const [modal, setModal] = useState(null); // null = đóng · {} = tạo mới · {channel} = sửa const [testMsg, setTestMsg] = useState(null); const reload = () => { if (live && window.API) window.API.channels().then(cs => { if (Array.isArray(cs)) { setList(cs); setSel(cs[0] ? cs[0].id : null); } }).catch(() => {}); }; useEffect(() => { reload(); }, []); const cur = list.find(c => c.id === sel) || list[0]; const connected = list.filter(c => c.status === 'connected').length; const totalConvs = list.reduce((s, c) => s + (c.convs || 0), 0); const sync = id => { if (live) window.API.syncChannel(id).then(r => { if (r && r.channel) setList(ls => ls.map(c => c.id === id ? r.channel : c)); }).catch(() => {}); else setList(ls => ls.map(c => c.id === id ? { ...c, lastSync: new Date().toISOString(), convs: c.convs + Math.floor(Math.random() * 20) } : c)); }; const reauth = id => { if (live) window.API.reauthChannel(id).then(rc => setList(ls => ls.map(c => c.id === id ? rc : c))).catch(() => {}); else setList(ls => ls.map(c => c.id === id ? { ...c, status: 'connected', tokenDays: 90 } : c)); }; const test = id => { setTestMsg({ level: 'pending', text: 'Đang kiểm tra…' }); if (live) window.API.testChannel(id).then(r => setTestMsg({ level: r.level || (r.ok ? 'ok' : 'fail'), text: r.detail })).catch(e => setTestMsg({ level: 'fail', text: 'Lỗi: ' + e.message })); else setTestMsg({ level: 'warn', text: 'Chế độ mock — không kiểm tra kết nối thật được.' }); }; const purge = id => { const after = ls => { const n = ls.filter(c => c.id !== id); if (sel === id && n[0]) setSel(n[0].id); return n; }; if (live) window.API.purgeChannel(id).then(() => setList(after)).catch(() => {}); else setList(after); }; const create = async data => { if (live) { const nc = await window.API.createChannel(data); setList(ls => [...ls, nc]); setSel(nc.id); } else { const nc = { id: 'ch_' + Date.now(), ...data, status: 'connected', convs: 0, lastSync: null, tokenDays: 90, mode: data.mode || 'Mỗi 15 phút', syncHistory: [] }; setList(ls => [...ls, nc]); setSel(nc.id); } }; const saveEdit = async data => { const id = (modal && modal.channel && modal.channel.id) || sel; if (live) { const uc = await window.API.updateChannel(id, data); if (uc && uc.id) setList(ls => ls.map(c => c.id === uc.id ? uc : c)); } else setList(ls => ls.map(c => c.id === id ? { ...c, ...data } : c)); }; const chMeta = ch => (AAU.channels && AAU.channels[ch]) || { color: '#8a8f98', short: (ch || '?').toUpperCase(), label: ch }; const history = (cur && cur.syncHistory && cur.syncHistory.length) ? cur.syncHistory : SYNC_HISTORY; return (
{modal && setModal(null)} onSubmit={modal.channel ? saveEdit : create} />} setModal({})}>Kết nối kênh mới} />
{list.length === 0 ? ( setModal({})}>Kết nối kênh mới} /> ) : (
{list.map(c => { const st = CH_STATUS[c.status]; const meta = chMeta(c.ch); return ( setSel(c.id)} style={{ cursor: 'pointer', background: c.id === sel ? 'var(--p-bg-surface-secondary)' : undefined }}> ); })}
KênhTrạng tháiHội thoạiĐồng bộ gần nhất
{meta.short[0]}
{c.name}
{meta.label} · {c.handle}
{st.label} {c.convs ? AAU.fmtNum(c.convs) : '—'} {c.lastSync ? AAU.relTime ? relTime(c.lastSync) : fmtTime(c.lastSync) : '—'}
{CH_STATUS[cur.status].label}}>
Token
{cur.tokenDays == null ? '—' : cur.tokenDays < 0 ? 'Đã hết hạn' : 'Còn ' + cur.tokenDays + ' ngày'}
Lịch đồng bộ
{cur.mode}
{cur.status === 'error' &&
Token hết hạn. Cần Reauth để tiếp tục đồng bộ hội thoại.
}
{testMsg && (() => { const M = { ok: ['checkCircle', '#0a7d4f', 'var(--p-success-bg)'], warn: ['alert', '#9a6700', 'var(--p-warning-bg)'], fail: ['alert', '#c4320a', 'var(--p-critical-bg)'], pending: ['refresh', '#6b7280', 'var(--p-bg-surface-secondary)'] }; const [ic, col, bg] = M[testMsg.level] || M.pending; return
{testMsg.text}
; })()}
LỊCH SỬ ĐỒNG BỘ
{history.map((h, i) => )}
Thời điểmLoạiTin mớiTrạng thái
{fmtTime(h.t)}{h.kind}{h.added}{h.status === 'ok' ? {h.dur} : {h.dur}}
)}
); } // Nguồn quảng cáo (Ads) — cùng trang Nguồn kết nối nhưng TÁCH namespace với kênh chat // (Quyết định #2: chat = Inbox/QC · ads = đo hiệu quả Marketing). Tái dùng cổng FB Ads. function AdsSourcesSection() { const live = !!(window.API && window.API.enabled); const [conn, setConn] = useState(null); const [tick, setTick] = useState(0); const [showConn, setShowConn] = useState(false); useEffect(() => { if (live && window.API.adsConn) window.API.adsConn().then(setConn).catch(() => { }); }, [live, tick]); const connected = !!(conn && conn.connected); const hasModal = typeof FbAdsConnectModal !== 'undefined'; return ( {showConn && hasModal && setShowConn(false)} onSaved={() => setTick(n => n + 1)} />}
Nền tảngTrạng tháiTài khoảnToken
F
Facebook Ads
{connected ? Đã kết nối : Chưa kết nối} {connected ? (conn.accountName || conn.adAccount) : '—'} {connected ? (conn.tokenMask || '••••') : '—'}
G
Google / TikTok Ads
Sắp hỗ trợ
{live ? 'Cổng này gắn 1 lần (token ads_read + ad account), sống qua restart. Cũng có ở Marketing → Ad Tracking.' : 'Bật LIVE (?api=http://localhost:8080) để kết nối nguồn ads thật.'}
); } /* ───────────────────────── 2 · AI COST (cost_logs) ───────────────────────── */ const COST_LOGS = [ { t: nowMinus(8), provider: 'claude', model: 'claude-sonnet-4', tin: 18240, tout: 3120, usd: 0.0962 }, { t: nowMinus(46), provider: 'claude', model: 'claude-sonnet-4', tin: 22150, tout: 4080, usd: 0.1183 }, { t: nowMinus(92), provider: 'gemini', model: 'gemini-2.5-flash', tin: 31200, tout: 5400, usd: 0.0146 }, { t: nowMinus(140), provider: 'claude', model: 'claude-haiku-3.5', tin: 12800, tout: 2200, usd: 0.0184 }, { t: nowMinus(220), provider: 'gemini', model: 'gemini-2.5-pro', tin: 28400, tout: 6100, usd: 0.0712 }, { t: nowMinus(300), provider: 'claude', model: 'claude-sonnet-4', tin: 19600, tout: 3640, usd: 0.1042 }, ]; const COST_BY_DAY = [ { l: '28/5', v: 1.82, color: '#7c3aed' }, { l: '29/5', v: 2.41, color: '#7c3aed' }, { l: '30/5', v: 1.96, color: '#7c3aed' }, { l: '31/5', v: 3.12, color: '#7c3aed' }, { l: '01/6', v: 2.74, color: '#7c3aed' }, { l: '02/6', v: 2.18, color: '#0084ff' }, ]; function AICost() { const [provider, setProvider] = useState('all'); const rows = COST_LOGS.filter(r => provider === 'all' || r.provider === provider); const totalUsd = rows.reduce((s, r) => s + r.usd, 0); const totalTok = rows.reduce((s, r) => s + r.tin + r.tout, 0); const monthUsd = 14.23; return (
Xuất CSV} />
{}} style={{ width: 150 }} />
}> {rows.map((r, i) => ( ))}
Thời điểmProviderModelInputOutputUSDVND
{fmtTime(r.t)} {r.provider} {r.model} {AAU.fmtNum(r.tin)} {AAU.fmtNum(r.tout)} ${r.usd.toFixed(4)} {AAU.fmtVND(r.usd * FX_RATE)}
Tổng:${totalUsd.toFixed(4)}{AAU.fmtVND(totalUsd * FX_RATE)}
); } /* ───────────────────────── 3 · NHẬT KÝ HOẠT ĐỘNG (activity_logs) ───────────────────────── */ const ACT_META = { 'job.run': { tone: 'info', label: 'Chạy job' }, 'job.create': { tone: 'success', label: 'Tạo job' }, 'job.delete': { tone: 'warning', label: 'Xóa job' }, 'ai.error': { tone: 'critical', label: 'Lỗi AI' }, 'notification': { tone: 'info', label: 'Thông báo' }, 'settings': { tone: 'neutral', label: 'Cài đặt' }, 'channel.sync': { tone: 'info', label: 'Đồng bộ kênh' }, 'auth.login': { tone: 'neutral', label: 'Đăng nhập' }, }; const ACT_LOGS = [ { t: nowMinus(4), action: 'job.run', user: 'system', detail: 'Job "Chấm QC chat ngoài giờ" chạy · 42 hội thoại · 38 PASS / 4 FAIL', err: null }, { t: nowMinus(18), action: 'channel.sync', user: 'system', detail: 'Đồng bộ Facebook Page · +34 tin nhắn mới', err: null }, { t: nowMinus(33), action: 'settings', user: 'thu.nguyen@aau.vn', detail: 'Cập nhật model chấm QC: claude-haiku → claude-sonnet-4', err: null }, { t: nowMinus(70), action: 'ai.error', user: 'system', detail: 'Gọi Gemini thất bại khi chấm batch #1182', err: 'rate_limit_exceeded (429)' }, { t: nowMinus(95), action: 'channel.sync', user: 'system', detail: 'Đồng bộ Instagram thất bại', err: 'token expired' }, { t: nowMinus(140), action: 'job.create', user: 'an.le@aau.vn', detail: 'Tạo job "Phân loại chủ đề inbox" (classification)', err: null }, { t: nowMinus(220), action: 'notification', user: 'system', detail: 'Gửi cảnh báo FAIL qua Telegram → nhóm QC HCM', err: null }, { t: nowMinus(300), action: 'auth.login', user: 'thu.nguyen@aau.vn', detail: 'Đăng nhập từ IP 14.161.xx.xx', err: null }, { t: nowMinus(360), action: 'job.delete', user: 'an.le@aau.vn', detail: 'Xóa job test "demo-qc-2"', err: null }, ]; function ActivityLog() { const [filter, setFilter] = useState('all'); const opts = [{ value: 'all', label: 'Tất cả hành động' }, ...Object.keys(ACT_META).map(k => ({ value: k, label: ACT_META[k].label }))]; const rows = ACT_LOGS.filter(r => filter === 'all' || r.action === filter); return (
} /> {rows.map((r, i) => { const m = ACT_META[r.action]; return ( ); })}
Thời điểmHành độngNgườiChi tiếtLỗi
{fmtTime(r.t)} {m.label} {r.user === 'system' ? system : r.user} {r.detail} {r.err && {r.err}}
); } /* ───────────────────────── 4 · MCP CONNECTIONS (mcp/clients) ───────────────────────── */ function MCPConnections() { const [clients, setClients] = useState([ { id: 'm1', name: 'Claude Web', client_id: 'mcp_a1b2c3d4e5', redirect: ['https://claude.ai/api/mcp/auth_callback'], scopes: ['read', 'write'], created: '2026-05-21T09:12:00' }, { id: 'm2', name: 'Claude Desktop — máy Thu', client_id: 'mcp_f6g7h8i9j0', redirect: ['http://localhost:5173/callback'], scopes: ['read'], created: '2026-05-28T14:40:00' }, ]); const [modal, setModal] = useState(false); const revoke = id => setClients(cs => cs.filter(c => c.id !== id)); return (
setModal(true)}>Tạo kết nối} />
Mỗi kết nối cấp một client_id + secret để nối Claude. Cấu hình Redirect URI khớp với claude.ai/api/mcp/auth_callback và scope read/write.
{clients.map(c => ( ))} {clients.length === 0 && }
TênClient IDRedirect URIsScopesTạo lúc
{c.name} {c.client_id} {c.redirect.map(u => {u})}
{c.scopes.map(s => {s})}
{AAU.fmtDate(c.created.slice(0, 10))}
Chưa có kết nối MCP
Tạo kết nối để Claude truy vấn dữ liệu CQA.
{modal && setModal(false)} onCreate={c => setClients(cs => [c, ...cs])} />}
); } function MCPCreateModal({ onClose, onCreate }) { const [name, setName] = useState(''); const [redirect, setRedirect] = useState('https://claude.ai/api/mcp/auth_callback'); const [scopes, setScopes] = useState(['read', 'write']); const [gen, setGen] = useState(null); const toggleScope = s => setScopes(ss => ss.includes(s) ? ss.filter(x => x !== s) : [...ss, s]); const generate = () => { const cid = 'mcp_' + Math.random().toString(36).slice(2, 12); const secret = 'sk_mcp_' + Math.random().toString(36).slice(2, 14) + Math.random().toString(36).slice(2, 10); setGen({ client_id: cid, secret }); onCreate({ id: 'm' + Date.now(), name, client_id: cid, redirect: [redirect], scopes, created: new Date().toISOString() }); }; return ( Đóng : <>}> {!gen ? (
{[['read', 'Đọc dữ liệu (hội thoại, điểm QC, vi phạm)'], ['write', 'Ghi / kích hoạt (chạy job, đồng bộ)']].map(([s, d]) => (
toggleScope(s)}> toggleScope(s)} />
{s}
{d}
))}
) : (
Secret chỉ hiện 1 lần. Hãy copy ngay — sau khi đóng sẽ không xem lại được.
{gen.client_id}
{gen.secret}
)}
); } /* ───────────────────────── 5 · NHẬT KÝ THÔNG BÁO (notification_logs) ───────────────────────── */ const NOTIF_LOGS = [ { id: 'n1', t: nowMinus(6), channel: 'telegram', to: 'Nhóm QC HCM', status: 'sent', subject: null, body: '⚠️ 4 hội thoại FAIL QC hôm nay. Top vi phạm: "Báo giá khi chưa qualify". Xem chi tiết tại CQA › QC Review.' }, { id: 'n2', t: nowMinus(70), channel: 'email', to: 'thu.nguyen@aau.vn', status: 'sent', subject: 'Báo cáo QC cuối ngày 02/06', body: 'Tổng 42 hội thoại đã chấm · Pass rate 90% · Điểm TB 7.8. Chi tiết trong file đính kèm.' }, { id: 'n3', t: nowMinus(190), channel: 'telegram', to: 'Nhóm QC HCM', status: 'error', subject: null, body: 'Cảnh báo job treo', }, { id: 'n4', t: nowMinus(320), channel: 'email', to: 'an.le@aau.vn', status: 'sent', subject: 'Job hoàn tất', body: 'Job "Phân loại chủ đề inbox" đã chạy xong: 128 hội thoại được gắn thẻ.' }, ]; function NotificationLog() { const [exp, setExp] = useState(null); return (
{NOTIF_LOGS.map(n => ( {exp === n.id && } ))}
Thời điểmKênhNgười nhậnTrạng thái
{fmtTime(n.t)} {n.channel} {n.to} {n.status === 'sent' ? Đã gửi : Lỗi}
{n.subject &&
Tiêu đề: {n.subject}
}
{n.body}
{n.status === 'error' &&
Lỗi: bot không có quyền gửi vào group (chat not found)
}
); } /* ───────────────────────── 6 · AGENTS CENTER (agents API) ───────────────────────── */ const AGENTS = [ { name: 'cqa.sync', version: '1.0.0', health: 'healthy', desc: 'Đồng bộ tin nhắn từ kênh ngoài (Zalo OA, Facebook) vào CQA', caps: ['sync_all', 'sync_channel', 'query:conversations', 'query:messages'], lastRun: nowMinus(18), runs: 142 }, { name: 'cqa.qc', version: '1.0.0', health: 'healthy', desc: 'Chấm chất lượng CSKH theo bộ quy tắc bằng AI', caps: ['analyze_quality', 'query:violations', 'query:scores'], lastRun: nowMinus(4), runs: 388 }, { name: 'cqa.classify', version: '1.0.0', health: 'idle', desc: 'Phân loại & gắn thẻ hội thoại bằng AI theo luật', caps: ['classify_conversations', 'query:tags', 'query:rules'], lastRun: nowMinus(140), runs: 96 }, ]; const AGENT_RUNS = [ { t: nowMinus(4), agent: 'cqa.qc', action: 'analyze_quality', status: 'success', summary: '42 hội thoại · 4 vi phạm' }, { t: nowMinus(18), agent: 'cqa.sync', action: 'sync_all', status: 'success', summary: '+67 tin nhắn / 3 kênh' }, { t: nowMinus(140), agent: 'cqa.classify', action: 'classify_conversations', status: 'partial', summary: '128 gắn thẻ · 2 lỗi' }, ]; const HEALTH = { healthy: { tone: 'success', label: 'Healthy', dot: true }, idle: { tone: 'neutral', label: 'Nghỉ' }, error: { tone: 'critical', label: 'Lỗi' } }; function AgentsCenter() { return (
Tài liệu API} />
{AGENTS.map(a => { const h = HEALTH[a.health]; return (
{a.name}
v{a.version}
{h.label}
{a.desc}
CAPABILITIES
{a.caps.map(c => {c})}
{a.runs} lần chạygần nhất {fmtTime(a.lastRun)}
); })}
{AGENT_RUNS.map((r, i) => ( ))}
Thời điểmAgentActionKết quảTóm tắt
{fmtTime(r.t)} {r.agent} {r.action} {r.status} {r.summary}
); } /* ───────────────────────── 7 · DỮ LIỆU MẪU (demo data) ───────────────────────── */ function DemoData() { const [loaded, setLoaded] = useState(true); const [busy, setBusy] = useState(false); const act = fn => { setBusy(true); setTimeout(() => { fn(); setBusy(false); }, 700); }; const SAMPLE = [['Hội thoại mẫu', 240], ['Tin nhắn', 3180], ['Kết quả QC', 156], ['Thẻ phân loại', 128], ['Job mẫu', 4]]; return (
Đã nạp : Chưa nạp}> {loaded ? (
Tenant AAU Academy — HCM đang chứa dữ liệu mẫu:
{SAMPLE.map(([k, v]) => )}
{k}{AAU.fmtNum(v)}
) :
Chưa có dữ liệu mẫu. Nạp bộ demo để xem thử dashboard, QC và inbox với dữ liệu thật.
}
Lưu ý: “Xóa & làm lại” sẽ xóa toàn bộ dữ liệu mẫu hiện có rồi nạp lại từ đầu. Không ảnh hưởng dữ liệu thật đã đồng bộ.
{busy &&
Đang xử lý…
}
); } Object.assign(window, { ChatChannels, AICost, ActivityLog, MCPConnections, MCPCreateModal, NotificationLog, AgentsCenter, DemoData }); /* ───────────────────────── Nhật ký cập nhật (Changelog) ───────────────────────── */ const CL_META = { feature: { icon: 'sparkles', color: '#0a7d4f', bg: 'var(--p-success-bg)', label: 'Tính năng' }, fix: { icon: 'alert', color: '#c4320a', bg: 'var(--p-critical-bg)', label: 'Sửa lỗi' }, improve: { icon: 'bolt', color: '#1a4fa3', bg: 'var(--p-info-bg)', label: 'Cải thiện' }, infra: { icon: 'settings', color: '#6d28d9', bg: 'var(--p-bg-surface-tertiary)', label: 'Hạ tầng' }, }; function ChangelogScreen() { const log = (typeof window !== 'undefined' && window.CHANGELOG) || []; const totalFeat = log.reduce((s, d) => s + d.entries.filter(e => e.type === 'feature').length, 0); const totalFix = log.reduce((s, d) => s + d.entries.filter(e => e.type === 'fix').length, 0); return (
{log.length === 0 && } {log.map((day, di) => (
{day.entries.map((e, ei) => { const m = CL_META[e.type] || CL_META.improve; return (
{m.label} {e.area && {e.area}} {e.title}
{e.detail}
{e.files && e.files.length > 0 &&
{e.files.join(' · ')}
}
); })}
))}
); } window.ChangelogScreen = ChangelogScreen; /* ───────────────────────── Cấu hình AI (model cho chat + QC scan) ───────────────────────── */ const AI_MODELS = { claude: [ { value: 'claude-opus-4-8', label: 'Claude Opus 4.8 (mạnh nhất)' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (cân bằng)' }, { value: 'claude-haiku-4-5', label: 'Claude Haiku 4.5 (nhanh/rẻ)' }, ], gemini: [ { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash (rẻ)' }, ], }; function AISettings() { const live = !!(window.API && window.API.enabled); const [cfg, setCfg] = useState(null); const [saved, setSaved] = useState(false); const [busy, setBusy] = useState(false); useEffect(() => { if (live && window.API.aiConfig) window.API.aiConfig().then(setCfg).catch(() => { }); }, [live]); const set = (k, v) => { setCfg(c => ({ ...c, [k]: v })); setSaved(false); }; const setProvider = p => setCfg(c => ({ ...c, provider: p, model: (AI_MODELS[p][0] || {}).value })); const save = async () => { if (!cfg || busy) return; setBusy(true); try { const r = await window.API.saveAiConfig({ provider: cfg.provider, model: cfg.model, qcEngine: cfg.qcEngine, defaultMode: cfg.defaultMode }); setCfg(r); setSaved(true); } catch (e) { window.alert('Lưu lỗi: ' + (e && e.message || e)); } finally { setBusy(false); } }; const head = ; if (!live) return
{head}
; if (!cfg) return
{head}
Đang tải cấu hình…
; const models = AI_MODELS[cfg.provider] || AI_MODELS.claude; return (
{head} set('qcEngine', v)} options={[ { value: 'rule', label: 'Rule-based (miễn phí, theo từ khóa)' }, { value: 'ai', label: 'AI (dùng model trên — cần API key)' }, ]} /> {cfg.qcEngine === 'ai' &&
Chế độ AI cần cấu hình API key của {cfg.provider}. Chưa có key → QC vẫn chạy rule-based.
}