diff --git a/app/api/v1/ai_proxy.py b/app/api/v1/ai_proxy.py new file mode 100644 index 0000000..eda81ca --- /dev/null +++ b/app/api/v1/ai_proxy.py @@ -0,0 +1,40 @@ +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, Any, Dict, List + +router = APIRouter() + +class ProxyRequest(BaseModel): + url: str + headers: Dict[str, str] = {} + body: Dict[str, Any] = {} + +import json + +@router.post("/proxy") +async def proxy_llm(req: ProxyRequest): + try: + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + req.url, + headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')}, + json=req.body + ) + raw = resp.text + try: + return resp.json() + except json.JSONDecodeError: + try: + return json.loads(raw[:raw.find('\n')]) + except (json.JSONDecodeError, ValueError): + return {"content": raw} + except httpx.TimeoutException: + raise HTTPException(status_code=504, detail="AI provider timeout") + except httpx.ConnectError as e: + msg = f"Cannot connect to AI provider: {e}" + if 'localhost' in req.url or '127.0.0.1' in req.url: + msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)." + raise HTTPException(status_code=502, detail=msg) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/main.py b/app/main.py index d6342a7..53e2904 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,7 @@ from app.api.v1.auth import router as auth_router from app.api.v1.admin import router as admin_router from app.api.v1.projects import router as projects_router from app.api.v1.user_config import router as user_config_router +from app.api.v1.ai_proxy import router as ai_proxy_router from app.core.auth import seed_admin # Ensure storage directories exist @@ -45,6 +46,7 @@ app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"]) app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config"]) +app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"]) # Seed admin user on startup @app.on_event("startup") diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 922510a..9995668 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -2481,7 +2481,8 @@ const AuthModal = ({ }; const AIConfigModal = ({ isOpen, - onClose + onClose, + onConfigSaved }) => { if (!isOpen) return null; const defaultProvidersList = [{ @@ -2552,6 +2553,7 @@ const AIConfigModal = ({ try { const res = await window.SonicAPI.saveAIConfigs(providers); setMsg(res.message || 'Đã lưu cấu hình AI Providers thành công!'); + if (onConfigSaved) onConfigSaved(providers); } catch (err) { setError(err.message || 'Lỗi khi lưu cấu hình AI'); } finally { @@ -2594,7 +2596,38 @@ const AIConfigModal = ({ className: "truncate" }, p.name), p.is_active && /*#__PURE__*/React.createElement("span", { className: "w-2 h-2 rounded-full bg-emerald-400" - })))), activeProvider && /*#__PURE__*/React.createElement("form", { + }))), /*#__PURE__*/React.createElement("div", { + className: "flex gap-1 mt-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + const newId = 'provider_' + Date.now(); + setProviders(prev => [...prev, { + id: newId, + name: 'New Provider', + provider_type: 'openai_compatible', + api_base_url: 'https://api.openai.com/v1', + api_key: '', + model_name: 'gpt-4o-mini', + temperature: 0.7, + is_active: false + }]); + setSelectedId(newId); + }, + className: "flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded" + }, "+ Thêm"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + if (confirm(`Xóa provider "${providers.find(p => p.id === selectedId)?.name}"?`)) { + setProviders(prev => { + const filtered = prev.filter(p => p.id !== selectedId); + if (filtered.length > 0) setSelectedId(filtered[0].id); + return filtered; + }); + } + }, + className: "px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded" + }, "Xóa"))), activeProvider && /*#__PURE__*/React.createElement("form", { onSubmit: handleSave, className: "col-span-2 space-y-3.5" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { @@ -3004,6 +3037,19 @@ const App = () => { apiKey: localStorage.getItem('ai_api_key') || '', model: localStorage.getItem('ai_model') || 'deepseek-chat' }); + const [aiProviders, setAiProviders] = useState([]); + useEffect(() => { + (async () => { + try { + const data = await window.SonicAPI.getAIConfigs(); + if (data && data.providers) { + setAiProviders(data.providers); + const active = data.providers.find(p => p.is_active) || data.providers[0]; + if (active) setSelectedProviderId(active.id); + } + } catch (e) { /* server may not have config endpoint */ } + })(); + }, []); const [analysisState, setAnalysisState] = useState({ status: 'Sẵn sàng. Chạy AI để phân tích nhịp.', data: null, @@ -3014,6 +3060,7 @@ const App = () => { const [aiModel, setAiModel] = useState('GPT-4o'); const [aiActionLog, setAiActionLog] = useState([]); const [aiProcessing, setAiProcessing] = useState(false); + const [selectedProviderId, setSelectedProviderId] = useState(''); const [exportSettings, setExportSettings] = useState({ sampleRate: '44100', bitDepth: '16', @@ -3348,7 +3395,7 @@ const App = () => { const sidebarEl = document.getElementById('right-sidebar'); const sidebarHeight = sidebarEl ? sidebarEl.getBoundingClientRect().height : 400; const onMove = ev => { - const deltaY = ev.clientY - startY; + const deltaY = startY - ev.clientY; const pct = ((startHeight / 100 * sidebarHeight + deltaY) / sidebarHeight) * 100; const newPct = Math.max(20, Math.min(80, pct)); setMediaExplorerHeight(newPct); @@ -6922,6 +6969,58 @@ const App = () => { }, 800); }; + // ── AI Prompt Send to Active Provider ── + const handleAISend = async () => { + const prompt = aiPrompt.trim(); + if (!prompt) { showToast('Vui lòng nhập nội dung prompt.', 'warning'); return; } + setAiProcessing(true); + setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI...`, time: Date.now() }]); + try { + const selectedProvider = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null); + const provider = selectedProvider || aiConfig; + const baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`; + const apiKey = provider.api_key || provider.apiKey || ''; + const model = provider.model_name || provider.model || 'deepseek-chat'; + const dawContext = window.AIGateway.buildAIPromptContext({ + tracks, bpm, selectedTrackId, currentTime, selLeft, selRight + }); + const result = await window.AIGateway.executeAIPrompt({ + prompt, + provider: provider.name || 'default', + model, + apiKey, + baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), + dawContext, + tools: window.AIGateway.DEFAULT_TOOLS + }); + if (result.textResponse) { + setAiActionLog(prev => [...prev, { type: 'status', text: ` AI: ${result.textResponse}`, time: Date.now() }]); + } + if (result.functionCalls && result.functionCalls.length > 0) { + for (const fc of result.functionCalls) { + setAiActionLog(prev => [...prev, { type: 'info', text: ` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`, time: Date.now() }]); + const cmdName = fc.name.toUpperCase(); + if (window.DAWCommandDispatcher) { + try { + const cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); + setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ ${fc.name}: ${cmdResult.success ? 'thành công' : 'thất bại'}`, time: Date.now() }]); + } catch (cmdErr) { + setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]); + } + } + } + } + setAiActionLog(prev => [...prev, { type: 'status', text: ` Hoàn tất.`, time: Date.now() }]); + setAiPrompt(''); + setTimeout(() => lucide.createIcons(), 200); + } catch (err) { + setAiActionLog(prev => [...prev, { type: 'error', text: ` Lỗi: ${err.message}`, time: Date.now() }]); + showToast(`AI Error: ${err.message}`, 'error'); + } finally { + setAiProcessing(false); + } + }; + // ── Split Track at Playhead ── const handleSplitTrackAtTime = (trackId, clipId, time) => { const track = tracks.find(t => t.id === trackId); @@ -8087,42 +8186,22 @@ const App = () => { "data-lucide": "x", className: "w-3 h-3" }))))), /*#__PURE__*/React.createElement("div", { - className: "p-1.5 bg-[#141414] rounded border border-zinc-800 text-xs font-mono min-h-[20px]" - }, /*#__PURE__*/React.createElement("div", { - className: "text-zinc-500" - }, "// ", /*#__PURE__*/React.createElement("span", { - className: "text-zinc-300" - }, analysisState.status)), analysisState.data && /*#__PURE__*/React.createElement("div", { - className: "text-emerald-500 font-semibold" - }, "BPM: ", analysisState.data.bpm)), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-3 gap-1" - }, /*#__PURE__*/React.createElement("button", { - onClick: handleAIScan, - className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1" + className: "flex items-center gap-1.5 shrink-0" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "map-pin", - className: "w-3 h-3" - })), " Scan"), /*#__PURE__*/React.createElement("button", { - onClick: handleAICutToNewTrack, - disabled: analysisState.isRunning, - className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs border border-fuchsia-600 flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3 h-3" - })), " Cut"), /*#__PURE__*/React.createElement("button", { - onClick: handleAIAnalysicLoop, - disabled: analysisState.isRunning, - className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sparkles", - className: "w-3 h-3" - })), " Loop")), /*#__PURE__*/React.createElement("div", { + "data-lucide": "cpu", + className: "w-3 h-3 text-purple-400" + })), /*#__PURE__*/React.createElement("select", { + value: selectedProviderId, + onChange: e => setSelectedProviderId(e.target.value), + className: "flex-1 bg-[#141414] text-zinc-200 border border-zinc-700 rounded px-1 py-0.5 text-xs font-mono focus:outline-none focus:border-purple-600" + }, aiProviders.length === 0 ? /*#__PURE__*/React.createElement("option", { + value: "" + }, "Chưa có provider") : aiProviders.map(p => /*#__PURE__*/React.createElement("option", { + key: p.id, + value: p.id + }, p.name, p.is_active ? '' : ' (inactive)')))), /*#__PURE__*/React.createElement("div", { className: "border-t border-zinc-800 pt-1.5 mt-1" }, /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1" @@ -8138,15 +8217,15 @@ const App = () => { className: "w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-none", rows: 2, onKeyDown: e => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - window.executeAIPrompt(); + handleAISend(); } } }), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 mt-1" }, /*#__PURE__*/React.createElement("button", { - onClick: () => window.executeAIPrompt(), + onClick: handleAISend, disabled: aiProcessing, className: "flex-1 py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-xs border border-purple-500 flex items-center justify-center gap-1" }, aiProcessing ? 'Đang suy luận...' : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", { @@ -8162,10 +8241,10 @@ const App = () => { className: "px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700" }, "Clear")), /*#__PURE__*/React.createElement("div", { className: "text-xs text-zinc-600 mt-0.5" - }, "Ctrl+Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", { - className: "border-t border-zinc-800 pt-1.5 mt-1" + }, "Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", { + className: "border-t border-zinc-800 pt-1.5 mt-1 flex-1 min-h-0 flex flex-col" }, /*#__PURE__*/React.createElement("div", { - className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center justify-between" + className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center justify-between shrink-0" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center gap-1" }, /*#__PURE__*/React.createElement("i", { @@ -8186,9 +8265,9 @@ const App = () => { }, className: "text-xs text-amber-400 hover:text-amber-300 border border-amber-800 rounded px-1 py-0.5" }, "Undo"))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 p-1 max-h-[120px]" + className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 p-1 select-text" }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 italic" + className: "text-xs text-zinc-600 italic select-text" }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { key: i, className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` @@ -9637,7 +9716,12 @@ const App = () => { onClose: () => setProfileModalOpen(false) }), /*#__PURE__*/React.createElement(AIConfigModal, { isOpen: aiConfigModalOpen, - onClose: () => setAiConfigModalOpen(false) + onClose: () => setAiConfigModalOpen(false), + onConfigSaved: (providers) => { + setAiProviders(providers); + const active = providers.find(p => p.is_active) || providers[0]; + if (active) setSelectedProviderId(active.id); + } }), /*#__PURE__*/React.createElement(SystemManagerModal, { isOpen: systemManagerModalOpen, onClose: () => setSystemManagerModalOpen(false) diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index a3a1905..f07f238 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -2481,7 +2481,8 @@ const AuthModal = ({ }; const AIConfigModal = ({ isOpen, - onClose + onClose, + onConfigSaved }) => { if (!isOpen) return null; const defaultProvidersList = [{ @@ -2552,6 +2553,7 @@ const AIConfigModal = ({ try { const res = await window.SonicAPI.saveAIConfigs(providers); setMsg(res.message || 'Đã lưu cấu hình AI Providers thành công!'); + if (onConfigSaved) onConfigSaved(providers); } catch (err) { setError(err.message || 'Lỗi khi lưu cấu hình AI'); } finally { @@ -2594,7 +2596,38 @@ const AIConfigModal = ({ className: "truncate" }, p.name), p.is_active && /*#__PURE__*/React.createElement("span", { className: "w-2 h-2 rounded-full bg-emerald-400" - })))), activeProvider && /*#__PURE__*/React.createElement("form", { + }))), /*#__PURE__*/React.createElement("div", { + className: "flex gap-1 mt-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + const newId = 'provider_' + Date.now(); + setProviders(prev => [...prev, { + id: newId, + name: 'New Provider', + provider_type: 'openai_compatible', + api_base_url: 'https://api.openai.com/v1', + api_key: '', + model_name: 'gpt-4o-mini', + temperature: 0.7, + is_active: false + }]); + setSelectedId(newId); + }, + className: "flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded" + }, "+ Thêm"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + if (confirm(`Xóa provider "${providers.find(p => p.id === selectedId)?.name}"?`)) { + setProviders(prev => { + const filtered = prev.filter(p => p.id !== selectedId); + if (filtered.length > 0) setSelectedId(filtered[0].id); + return filtered; + }); + } + }, + className: "px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded" + }, "Xóa"))), activeProvider && /*#__PURE__*/React.createElement("form", { onSubmit: handleSave, className: "col-span-2 space-y-3.5" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { @@ -3004,6 +3037,19 @@ const App = () => { apiKey: localStorage.getItem('ai_api_key') || '', model: localStorage.getItem('ai_model') || 'deepseek-chat' }); + const [aiProviders, setAiProviders] = useState([]); + useEffect(() => { + (async () => { + try { + const data = await window.SonicAPI.getAIConfigs(); + if (data && data.providers) { + setAiProviders(data.providers); + const active = data.providers.find(p => p.is_active) || data.providers[0]; + if (active) setSelectedProviderId(active.id); + } + } catch (e) {/* server may not have config endpoint */} + })(); + }, []); const [analysisState, setAnalysisState] = useState({ status: 'Sẵn sàng. Chạy AI để phân tích nhịp.', data: null, @@ -3014,6 +3060,7 @@ const App = () => { const [aiModel, setAiModel] = useState('GPT-4o'); const [aiActionLog, setAiActionLog] = useState([]); const [aiProcessing, setAiProcessing] = useState(false); + const [selectedProviderId, setSelectedProviderId] = useState(''); const [exportSettings, setExportSettings] = useState({ sampleRate: '44100', bitDepth: '16', @@ -3348,7 +3395,7 @@ const App = () => { const sidebarEl = document.getElementById('right-sidebar'); const sidebarHeight = sidebarEl ? sidebarEl.getBoundingClientRect().height : 400; const onMove = ev => { - const deltaY = ev.clientY - startY; + const deltaY = startY - ev.clientY; const pct = (startHeight / 100 * sidebarHeight + deltaY) / sidebarHeight * 100; const newPct = Math.max(20, Math.min(80, pct)); setMediaExplorerHeight(newPct); @@ -6921,6 +6968,94 @@ const App = () => { }, 800); }; + // ── AI Prompt Send to Active Provider ── + const handleAISend = async () => { + const prompt = aiPrompt.trim(); + if (!prompt) { + showToast('Vui lòng nhập nội dung prompt.', 'warning'); + return; + } + setAiProcessing(true); + setAiActionLog(prev => [...prev, { + type: 'status', + text: ` ⏳ Đang gửi prompt đến AI...`, + time: Date.now() + }]); + try { + const selectedProvider = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null); + const provider = selectedProvider || aiConfig; + const baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`; + const apiKey = provider.api_key || provider.apiKey || ''; + const model = provider.model_name || provider.model || 'deepseek-chat'; + const dawContext = window.AIGateway.buildAIPromptContext({ + tracks, + bpm, + selectedTrackId, + currentTime, + selLeft, + selRight + }); + const result = await window.AIGateway.executeAIPrompt({ + prompt, + provider: provider.name || 'default', + model, + apiKey, + baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), + dawContext, + tools: window.AIGateway.DEFAULT_TOOLS + }); + if (result.textResponse) { + setAiActionLog(prev => [...prev, { + type: 'status', + text: ` AI: ${result.textResponse}`, + time: Date.now() + }]); + } + if (result.functionCalls && result.functionCalls.length > 0) { + for (const fc of result.functionCalls) { + setAiActionLog(prev => [...prev, { + type: 'info', + text: ` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`, + time: Date.now() + }]); + const cmdName = fc.name.toUpperCase(); + if (window.DAWCommandDispatcher) { + try { + const cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); + setAiActionLog(prev => [...prev, { + type: 'status', + text: ` ✅ ${fc.name}: ${cmdResult.success ? 'thành công' : 'thất bại'}`, + time: Date.now() + }]); + } catch (cmdErr) { + setAiActionLog(prev => [...prev, { + type: 'error', + text: ` ❌ ${fc.name}: ${cmdErr.message}`, + time: Date.now() + }]); + } + } + } + } + setAiActionLog(prev => [...prev, { + type: 'status', + text: ` Hoàn tất.`, + time: Date.now() + }]); + setAiPrompt(''); + setTimeout(() => lucide.createIcons(), 200); + } catch (err) { + setAiActionLog(prev => [...prev, { + type: 'error', + text: ` Lỗi: ${err.message}`, + time: Date.now() + }]); + showToast(`AI Error: ${err.message}`, 'error'); + } finally { + setAiProcessing(false); + } + }; + // ── Split Track at Playhead ── const handleSplitTrackAtTime = (trackId, clipId, time) => { const track = tracks.find(t => t.id === trackId); @@ -8174,42 +8309,22 @@ const App = () => { "data-lucide": "x", className: "w-3 h-3" }))))), /*#__PURE__*/React.createElement("div", { - className: "p-1.5 bg-[#141414] rounded border border-zinc-800 text-xs font-mono min-h-[20px]" - }, /*#__PURE__*/React.createElement("div", { - className: "text-zinc-500" - }, "// ", /*#__PURE__*/React.createElement("span", { - className: "text-zinc-300" - }, analysisState.status)), analysisState.data && /*#__PURE__*/React.createElement("div", { - className: "text-emerald-500 font-semibold" - }, "BPM: ", analysisState.data.bpm)), /*#__PURE__*/React.createElement("div", { - className: "grid grid-cols-3 gap-1" - }, /*#__PURE__*/React.createElement("button", { - onClick: handleAIScan, - className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1" + className: "flex items-center gap-1.5 shrink-0" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "map-pin", - className: "w-3 h-3" - })), " Scan"), /*#__PURE__*/React.createElement("button", { - onClick: handleAICutToNewTrack, - disabled: analysisState.isRunning, - className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs border border-fuchsia-600 flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "scissors", - className: "w-3 h-3" - })), " Cut"), /*#__PURE__*/React.createElement("button", { - onClick: handleAIAnalysicLoop, - disabled: analysisState.isRunning, - className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1" - }, /*#__PURE__*/React.createElement("span", { - className: "inline-flex items-center shrink-0" - }, /*#__PURE__*/React.createElement("i", { - "data-lucide": "sparkles", - className: "w-3 h-3" - })), " Loop")), /*#__PURE__*/React.createElement("div", { + "data-lucide": "cpu", + className: "w-3 h-3 text-purple-400" + })), /*#__PURE__*/React.createElement("select", { + value: selectedProviderId, + onChange: e => setSelectedProviderId(e.target.value), + className: "flex-1 bg-[#141414] text-zinc-200 border border-zinc-700 rounded px-1 py-0.5 text-xs font-mono focus:outline-none focus:border-purple-600" + }, aiProviders.length === 0 ? /*#__PURE__*/React.createElement("option", { + value: "" + }, "Chưa có provider") : aiProviders.map(p => /*#__PURE__*/React.createElement("option", { + key: p.id, + value: p.id + }, p.name, p.is_active ? '' : ' (inactive)')))), /*#__PURE__*/React.createElement("div", { className: "border-t border-zinc-800 pt-1.5 mt-1" }, /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1" @@ -8225,15 +8340,15 @@ const App = () => { className: "w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-none", rows: 2, onKeyDown: e => { - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - window.executeAIPrompt(); + handleAISend(); } } }), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 mt-1" }, /*#__PURE__*/React.createElement("button", { - onClick: () => window.executeAIPrompt(), + onClick: handleAISend, disabled: aiProcessing, className: "flex-1 py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-xs border border-purple-500 flex items-center justify-center gap-1" }, aiProcessing ? 'Đang suy luận...' : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", { @@ -8249,10 +8364,10 @@ const App = () => { className: "px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700" }, "Clear")), /*#__PURE__*/React.createElement("div", { className: "text-xs text-zinc-600 mt-0.5" - }, "Ctrl+Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", { - className: "border-t border-zinc-800 pt-1.5 mt-1" + }, "Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", { + className: "border-t border-zinc-800 pt-1.5 mt-1 flex-1 min-h-0 flex flex-col" }, /*#__PURE__*/React.createElement("div", { - className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center justify-between" + className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center justify-between shrink-0" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center gap-1" }, /*#__PURE__*/React.createElement("i", { @@ -8281,9 +8396,9 @@ const App = () => { }, className: "text-xs text-amber-400 hover:text-amber-300 border border-amber-800 rounded px-1 py-0.5" }, "Undo"))), /*#__PURE__*/React.createElement("div", { - className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 p-1 max-h-[120px]" + className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 p-1 select-text" }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { - className: "text-xs text-zinc-600 italic" + className: "text-xs text-zinc-600 italic select-text" }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { key: i, className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` @@ -9732,7 +9847,12 @@ const App = () => { onClose: () => setProfileModalOpen(false) }), /*#__PURE__*/React.createElement(AIConfigModal, { isOpen: aiConfigModalOpen, - onClose: () => setAiConfigModalOpen(false) + onClose: () => setAiConfigModalOpen(false), + onConfigSaved: providers => { + setAiProviders(providers); + const active = providers.find(p => p.is_active) || providers[0]; + if (active) setSelectedProviderId(active.id); + } }), /*#__PURE__*/React.createElement(SystemManagerModal, { isOpen: systemManagerModalOpen, onClose: () => setSystemManagerModalOpen(false) diff --git a/app/static/js/services/aiGateway.js b/app/static/js/services/aiGateway.js index 4308f2c..7f1d993 100644 --- a/app/static/js/services/aiGateway.js +++ b/app/static/js/services/aiGateway.js @@ -63,8 +63,26 @@ const AIGateway = (function() { } }]; + function parseOrigin(urlStr) { + try { const u = new URL(urlStr); return `${u.protocol}//${u.hostname}${u.port ? ':'+u.port : ''}`; } catch (_) { return null; } + } + + function isLocalhost(urlStr) { + try { + const u = new URL(urlStr); + return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0' || u.hostname === '::1'; + } catch (_) { return false; } + } + async function callLLM({ provider, model, apiKey, baseUrl, messages, tools, toolChoice }) { - const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`; + const base = baseUrl.replace(/\/$/, ''); + const url = `${base}/chat/completions`; + const origin = window.location.origin; + const urlOrigin = parseOrigin(url); + const appOrigin = parseOrigin(origin); + const sameOrigin = urlOrigin === appOrigin; + const targetIsLocal = isLocalhost(url); + const headers = { 'Content-Type': 'application/json', ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) @@ -77,15 +95,29 @@ const AIGateway = (function() { ...(toolChoice ? { tool_choice: toolChoice } : {}) }; - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body) - }); + let response; + + if (sameOrigin) { + response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + } else if (targetIsLocal && !isLocalhost(origin)) { + throw new Error(`AI provider local (${url}) không khả dụng từ domain từ xa (${origin}).\nHãy dùng provider từ xa (OpenAI, Anthropic...) hoặc dùng CORS plugin trình duyệt.`); + } else { + response = await fetch(`${origin}/api/v1/ai/proxy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, headers, body }) + }); + } if (!response.ok) { const errText = await response.text(); - throw new Error(`LLM API error ${response.status}: ${errText}`); + let detail = errText; + try { const j = JSON.parse(errText); if (j.detail) detail = j.detail; } catch (_) {} + throw new Error(detail); } return await response.json(); diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 0e82cf3..d2ae6ec 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ