diff --git a/app/api/v1/user_config.py b/app/api/v1/user_config.py index 8b8d636..7e9fab7 100644 --- a/app/api/v1/user_config.py +++ b/app/api/v1/user_config.py @@ -1,12 +1,31 @@ -import json +import json, os from fastapi import APIRouter, HTTPException, Header, Depends from pydantic import BaseModel from typing import Optional, List, Dict, Any import time from app.core.auth import decode_token +from app.config import settings router = APIRouter() +DATA_FILE = os.path.join(settings.PROCESSED_DIR, "user_configs.json") + +def _load_all(): + if not os.path.exists(DATA_FILE): + return {"ai_configs": {}, "preferences": {}} + try: + with open(DATA_FILE, "r") as f: + return json.load(f) + except: return {"ai_configs": {}, "preferences": {}} + +def _save_all(ai_configs=None, preferences=None): + data = _load_all() + if ai_configs is not None: data["ai_configs"] = ai_configs + if preferences is not None: data["preferences"] = preferences + os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True) + with open(DATA_FILE, "w") as f: + json.dump(data, f, indent=2) + def _get_user_id(authorization): if not authorization or not authorization.startswith("Bearer "): return "anonymous" @@ -16,9 +35,21 @@ def _get_user_id(authorization): return "anonymous" return payload.get("user_id", "anonymous") -# In-memory / per-user AI provider configurations storage dictionary -USER_AI_CONFIGS = {} -USER_PREFERENCES = {} +def _load_ai_configs(): + data = _load_all() + return data.get("ai_configs", {}) + +def _load_preferences(): + data = _load_all() + return data.get("preferences", {}) + +def _get_default_providers(): + return [ + {"id": "openai_default", "name": "OpenAI Official", "provider_type": "openai", "api_base_url": "https://api.openai.com/v1", "api_key": "", "model_name": "gpt-4o", "temperature": 0.7, "is_active": True}, + {"id": "openai_compat_default", "name": "OpenAI Compatible (Ollama/LocalAI/DeepSeek)", "provider_type": "openai_compatible", "api_base_url": "http://localhost:11434/v1", "api_key": "ollama", "model_name": "deepseek-r1", "temperature": 0.7, "is_active": False}, + {"id": "anthropic_default", "name": "Anthropic Claude", "provider_type": "anthropic", "api_base_url": "https://api.anthropic.com/v1", "api_key": "", "model_name": "claude-3-5-sonnet", "temperature": 0.7, "is_active": False}, + {"id": "gemini_default", "name": "Google Gemini", "provider_type": "gemini", "api_base_url": "https://generativelanguage.googleapis.com", "api_key": "", "model_name": "gemini-1.5-pro", "temperature": 0.7, "is_active": False} + ] class AIProviderSetting(BaseModel): id: str @@ -39,69 +70,34 @@ class SavePreferencesRequest(BaseModel): @router.get("/preferences") async def get_user_preferences(authorization: Optional[str] = Header(None)): uid = _get_user_id(authorization) - return {"success": True, "preferences": USER_PREFERENCES.get(uid, {})} + prefs = _load_preferences() + return {"success": True, "preferences": prefs.get(uid, {})} @router.post("/preferences") async def save_user_preferences(req: SavePreferencesRequest, authorization: Optional[str] = Header(None)): uid = _get_user_id(authorization) - USER_PREFERENCES[uid] = req.preferences + prefs = _load_preferences() + prefs[uid] = req.preferences + _save_all(preferences=prefs) return {"success": True, "message": "Đã lưu cấu hình người dùng."} @router.get("/config/ai") async def get_user_ai_config(authorization: Optional[str] = Header(None)): uid = _get_user_id(authorization) - if uid not in USER_AI_CONFIGS: - USER_AI_CONFIGS[uid] = [ - { - "id": "openai_default", - "name": "OpenAI Official", - "provider_type": "openai", - "api_base_url": "https://api.openai.com/v1", - "api_key": "", - "model_name": "gpt-4o", - "temperature": 0.7, - "is_active": True - }, - { - "id": "openai_compat_default", - "name": "OpenAI Compatible (Ollama/LocalAI/DeepSeek)", - "provider_type": "openai_compatible", - "api_base_url": "http://localhost:11434/v1", - "api_key": "ollama", - "model_name": "deepseek-r1", - "temperature": 0.7, - "is_active": False - }, - { - "id": "anthropic_default", - "name": "Anthropic Claude", - "provider_type": "anthropic", - "api_base_url": "https://api.anthropic.com/v1", - "api_key": "", - "model_name": "claude-3-5-sonnet", - "temperature": 0.7, - "is_active": False - }, - { - "id": "gemini_default", - "name": "Google Gemini", - "provider_type": "gemini", - "api_base_url": "https://generativelanguage.googleapis.com", - "api_key": "", - "model_name": "gemini-1.5-pro", - "temperature": 0.7, - "is_active": False - } - ] + configs = _load_ai_configs() + if uid not in configs: + configs[uid] = _get_default_providers() return { "success": True, - "providers": USER_AI_CONFIGS[uid] + "providers": configs[uid] } @router.post("/config/ai") async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)): uid = _get_user_id(authorization) - USER_AI_CONFIGS[uid] = [p.dict() for p in req.providers] + configs = _load_ai_configs() + configs[uid] = [p.dict() for p in req.providers] + _save_all(ai_configs=configs) return { "success": True, "message": "Đã lưu cấu hình AI Providers thành công!" diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 9390eb2..a88cb92 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -751,6 +751,21 @@ const TempoTrackLane = ({ ctx.font = 'bold 9px Inter, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11); + } else if (zoom >= 2) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + if (zoom >= 5) { + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.font = '8px Inter, sans-serif'; + ctx.textAlign = 'left'; + const bar = Math.ceil(beatNum / 4); + const beat = ((beatNum - 1) % 4) + 1; + ctx.fillText(`${bar}:${beat}`, localX + 2, 9); + } } else { ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 1; @@ -2590,7 +2605,9 @@ const AIConfigModal = ({ className: "text-xs uppercase font-bold text-slate-400 block mb-2" }, "Providers"), providers.map(p => /*#__PURE__*/React.createElement("button", { key: p.id, + tabIndex: 0, onClick: () => setSelectedId(p.id), + onKeyDown: e => { if (e.key === ' ') { e.preventDefault(); updateProviderField(p.id, 'is_active', !p.is_active); } }, className: `w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId === p.id ? 'bg-cyan-600 text-white shadow' : 'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}` }, /*#__PURE__*/React.createElement("span", { className: "truncate" @@ -2627,7 +2644,15 @@ const AIConfigModal = ({ } }, 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", { + }, "Xóa"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { + onClick: function(e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos > 0) { var tmp = provs[pos]; provs[pos] = provs[pos-1]; provs[pos-1] = tmp; setProviders(provs); } }, + className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", + title: "Di chuyển lên" + }, "▲"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { + onClick: function(e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos < provs.length - 1) { var tmp = provs[pos]; provs[pos] = provs[pos+1]; provs[pos+1] = tmp; setProviders(provs); } }, + className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", + title: "Di chuyển xuống" + }, "▼"))), activeProvider && /*#__PURE__*/React.createElement("form", { onSubmit: handleSave, className: "col-span-2 space-y-3.5" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { @@ -2993,6 +3018,8 @@ const App = () => { const [isPlaying, setIsPlaying] = useState(false); const [selectionStart, setSelectionStart] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null); + const selectionRef = useRef({ start: null, end: null }); + selectionRef.current = { start: selectionStart, end: selectionEnd }; const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track) const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null); const [localSelectionStart, setLocalSelectionStart] = useState(null); @@ -7050,7 +7077,7 @@ const App = () => { 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() }]); + setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ ${fc.name}: ${cmdResult.success ? 'thành công' : 'thất bại: ' + (cmdResult.error || 'unknown')}`, time: Date.now() }]); } catch (cmdErr) { setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]); } @@ -7372,6 +7399,7 @@ const App = () => { setSelectionMode('global'); setSelectionStart(start); setSelectionEnd(end); + selectionRef.current = { start, end }; return { success: true, start: parseFloat(start.toFixed(3)), end: parseFloat(end.toFixed(3)), length: parseFloat((end - start).toFixed(3)) }; }, cutAudio: (args) => { @@ -7380,15 +7408,18 @@ const App = () => { if (!track) return { success: false, error: 'Track not found' }; if (!track.buffer) return { success: false, error: 'Track has no audio buffer' }; const barDur = 60 / parseInt(bpm || 120) * 4; + const sel = selectionRef.current; let rawStart, rawEnd; - if (args.start_time !== undefined) rawStart = args.start_time; - else if (args.start_bar !== undefined) rawStart = args.start_bar * barDur; - else if (selectionStart !== null) rawStart = selectionStart; - else return { success: false, error: 'No selection or start position provided. Use set_selection first or provide start_time/start_bar.' }; - if (args.end_time !== undefined) rawEnd = args.end_time; - else if (args.length_bars !== undefined) rawEnd = rawStart + args.length_bars * barDur; - else if (selectionEnd !== null && selectionEnd > rawStart) rawEnd = selectionEnd; - else return { success: false, error: 'No end position provided. Use set_selection first or provide end_time/length_bars.' }; + if (args.start_time !== undefined && args.start_time !== null) rawStart = args.start_time; + else if (args.start_bar !== undefined && args.start_bar !== null) rawStart = args.start_bar * barDur; + else if (sel.start !== null) rawStart = sel.start; + else rawStart = currentTime; + if (args.end_time !== undefined && args.end_time !== null) rawEnd = args.end_time; + else if (args.end_bar !== undefined && args.end_bar !== null) rawEnd = args.end_bar * barDur; + else if (args.length_bars !== undefined && args.length_bars !== null) rawEnd = rawStart + args.length_bars * barDur; + else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end; + else return { success: false, error: 'No end position provided. Provide end_time, end_bar, or length_bars.' }; + if (rawEnd <= rawStart) return { success: false, error: 'End position must be after start position.' }; const buffer = track.buffer; const ctx = getAudioContext(); const snap = args.snap_silence !== false; @@ -7424,6 +7455,7 @@ const App = () => { return updated; }); setSelectedTrackId(newId); + selectionRef.current = { start: 0, end: slicedBuffer.duration }; clearLocalSelection(); setSelectionMode('global'); setSelectionStart(0); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 2153860..0eae3ea 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -751,6 +751,21 @@ const TempoTrackLane = ({ ctx.font = 'bold 9px Inter, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11); + } else if (zoom >= 2) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + if (zoom >= 5) { + ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.font = '8px Inter, sans-serif'; + ctx.textAlign = 'left'; + const bar = Math.ceil(beatNum / 4); + const beat = (beatNum - 1) % 4 + 1; + ctx.fillText(`${bar}:${beat}`, localX + 2, 9); + } } else { ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 1; @@ -2590,7 +2605,14 @@ const AIConfigModal = ({ className: "text-xs uppercase font-bold text-slate-400 block mb-2" }, "Providers"), providers.map(p => /*#__PURE__*/React.createElement("button", { key: p.id, + tabIndex: 0, onClick: () => setSelectedId(p.id), + onKeyDown: e => { + if (e.key === ' ') { + e.preventDefault(); + updateProviderField(p.id, 'is_active', !p.is_active); + } + }, className: `w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId === p.id ? 'bg-cyan-600 text-white shadow' : 'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}` }, /*#__PURE__*/React.createElement("span", { className: "truncate" @@ -2627,7 +2649,47 @@ const AIConfigModal = ({ } }, 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", { + }, "Xóa"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { + onClick: function (e) { + e.stopPropagation(); + var provs = providers.slice(); + var pos = -1; + for (var pi = 0; pi < provs.length; pi++) { + if (provs[pi].id === selectedId) { + pos = pi; + break; + } + } + if (pos > 0) { + var tmp = provs[pos]; + provs[pos] = provs[pos - 1]; + provs[pos - 1] = tmp; + setProviders(provs); + } + }, + className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", + title: "Di chuyển lên" + }, "▲"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { + onClick: function (e) { + e.stopPropagation(); + var provs = providers.slice(); + var pos = -1; + for (var pi = 0; pi < provs.length; pi++) { + if (provs[pi].id === selectedId) { + pos = pi; + break; + } + } + if (pos < provs.length - 1) { + var tmp = provs[pos]; + provs[pos] = provs[pos + 1]; + provs[pos + 1] = tmp; + setProviders(provs); + } + }, + className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", + title: "Di chuyển xuống" + }, "▼"))), activeProvider && /*#__PURE__*/React.createElement("form", { onSubmit: handleSave, className: "col-span-2 space-y-3.5" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { @@ -2993,6 +3055,14 @@ const App = () => { const [isPlaying, setIsPlaying] = useState(false); const [selectionStart, setSelectionStart] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null); + const selectionRef = useRef({ + start: null, + end: null + }); + selectionRef.current = { + start: selectionStart, + end: selectionEnd + }; const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track) const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null); const [localSelectionStart, setLocalSelectionStart] = useState(null); @@ -7117,7 +7187,7 @@ const App = () => { 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'}`, + text: ` ✅ ${fc.name}: ${cmdResult.success ? 'thành công' : 'thất bại: ' + (cmdResult.error || 'unknown')}`, time: Date.now() }]); } catch (cmdErr) { @@ -7536,6 +7606,10 @@ const App = () => { setSelectionMode('global'); setSelectionStart(start); setSelectionEnd(end); + selectionRef.current = { + start, + end + }; return { success: true, start: parseFloat(start.toFixed(3)), @@ -7555,14 +7629,16 @@ const App = () => { error: 'Track has no audio buffer' }; const barDur = 60 / parseInt(bpm || 120) * 4; + const sel = selectionRef.current; let rawStart, rawEnd; - if (args.start_time !== undefined) rawStart = args.start_time;else if (args.start_bar !== undefined) rawStart = args.start_bar * barDur;else if (selectionStart !== null) rawStart = selectionStart;else return { + if (args.start_time !== undefined && args.start_time !== null) rawStart = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) rawStart = args.start_bar * barDur;else if (sel.start !== null) rawStart = sel.start;else rawStart = currentTime; + if (args.end_time !== undefined && args.end_time !== null) rawEnd = args.end_time;else if (args.end_bar !== undefined && args.end_bar !== null) rawEnd = args.end_bar * barDur;else if (args.length_bars !== undefined && args.length_bars !== null) rawEnd = rawStart + args.length_bars * barDur;else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end;else return { success: false, - error: 'No selection or start position provided. Use set_selection first or provide start_time/start_bar.' + error: 'No end position provided. Provide end_time, end_bar, or length_bars.' }; - if (args.end_time !== undefined) rawEnd = args.end_time;else if (args.length_bars !== undefined) rawEnd = rawStart + args.length_bars * barDur;else if (selectionEnd !== null && selectionEnd > rawStart) rawEnd = selectionEnd;else return { + if (rawEnd <= rawStart) return { success: false, - error: 'No end position provided. Use set_selection first or provide end_time/length_bars.' + error: 'End position must be after start position.' }; const buffer = track.buffer; const ctx = getAudioContext(); @@ -7615,6 +7691,10 @@ const App = () => { return updated; }); setSelectedTrackId(newId); + selectionRef.current = { + start: 0, + end: slicedBuffer.duration + }; clearLocalSelection(); setSelectionMode('global'); setSelectionStart(0); diff --git a/app/static/js/services/aiGateway.js b/app/static/js/services/aiGateway.js index 3ea9321..4a4340d 100644 --- a/app/static/js/services/aiGateway.js +++ b/app/static/js/services/aiGateway.js @@ -179,16 +179,17 @@ const AIGateway = (function() { } }, { name: 'cut_audio', - description: 'Cắt đoạn audio đang được chọn từ track nguồn, snap zero-crossing, tạo track mới chứa đoạn cắt', + description: 'Cắt một đoạn audio từ track nguồn. Tự động tạo selection, snap zero-crossing, và tạo track mới chứa đoạn cắt. Lệnh DUY NHẤT cho thao tác cắt - không cần gọi set_selection trước.', parameters: { type: 'object', properties: { track_id: { type: 'string', description: 'ID của track nguồn' }, - start_time: { type: 'number', description: 'Vị trí bắt đầu cắt (giây). Mặc định: dùng selection hiện tại.' }, + start_time: { type: 'number', description: 'Vị trí bắt đầu cắt (giây). Mặc định: dùng playhead hiện tại.' }, end_time: { type: 'number', description: 'Vị trí kết thúc cắt (giây). Mặc định: dùng selection hiện tại.' }, start_bar: { type: 'number', description: 'Bar bắt đầu cắt (0 = bar đầu). Dùng thay cho start_time.' }, - length_bars: { type: 'number', description: 'Độ dài cắt (bar). Dùng cùng start_bar.' }, - snap_silence: { type: 'boolean', description: 'Tự động snap vào điểm silence/gần silence gần nhất ở hai đầu (mặc định: true)' }, + end_bar: { type: 'number', description: 'Bar kết thúc cắt. VD: end_bar=12 cắt đến bar 12. Dùng thay cho end_time.' }, + length_bars: { type: 'number', description: 'Độ dài cắt (bar). Dùng cùng start_bar thay cho end_bar.' }, + snap_silence: { type: 'boolean', description: 'Tự động snap vào điểm zero-crossing gần nhất ở hai đầu (mặc định: true)' }, new_track_name: { type: 'string', description: 'Tên cho track mới. Mặc định: "Cut_"' } } } diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 43baa1b..5c960fe 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ