web ui 新增設定保存、載入及清除功能,並優化佈局模式選擇及相關UI,改善多語言支持。

This commit is contained in:
Minidoracat 2025-06-03 15:09:08 +08:00
parent 4e1f6c8bb3
commit ac05fd5b9a
9 changed files with 819 additions and 241 deletions

3
.gitignore vendored
View File

@ -17,4 +17,5 @@ venv*/
.DS_Store
.cursor/rules/
uv.lock
uv.lock
.mcp_feedback_settings.json

View File

@ -72,6 +72,11 @@
"error": "Execution Error",
"history": "Command History"
},
"combined": {
"description": "Combined mode: AI summary and feedback input are on the same page for easy comparison.",
"summaryTitle": "📋 AI Work Summary",
"feedbackTitle": "💬 Provide Feedback"
},
"settings": {
"title": "⚙️ Settings",
"description": "Adjust interface settings and preference options.",
@ -79,15 +84,25 @@
"currentLanguage": "Current Language",
"languageDesc": "Select interface display language",
"interface": "Interface Settings",
"combinedMode": "Combined Mode",
"combinedModeDesc": "Merge AI summary and feedback input in the same tab",
"layoutMode": "Interface Layout Mode",
"layoutModeDesc": "Select how AI summary and feedback input are displayed",
"separateMode": "Separate Mode",
"separateModeDesc": "AI summary and feedback are in separate tabs",
"combinedVertical": "Combined Mode (Vertical Layout)",
"combinedVerticalDesc": "AI summary on top, feedback input below, both on the same page",
"combinedHorizontal": "Combined Mode (Horizontal Layout)",
"combinedHorizontalDesc": "AI summary on left, feedback input on right, expanding summary viewing area",
"autoClose": "Auto Close Page",
"autoCloseDesc": "Automatically close page after submitting feedback",
"theme": "Theme",
"notifications": "Notifications",
"advanced": "Advanced Settings",
"save": "Save Settings",
"reset": "Reset",
"reset": "Reset Settings",
"resetDesc": "Clear all saved settings and restore to default state",
"resetConfirm": "Are you sure you want to reset all settings? This will clear all saved preferences.",
"resetSuccess": "Settings have been reset to default values",
"resetError": "Error occurred while resetting settings",
"timeout": "Connection Timeout (seconds)",
"autorefresh": "Auto Refresh",
"debug": "Debug Mode"

View File

@ -72,6 +72,11 @@
"error": "执行错误",
"history": "命令历史"
},
"combined": {
"description": "合并模式AI 摘要和反馈输入在同一页面中,方便对照查看。",
"summaryTitle": "📋 AI 工作摘要",
"feedbackTitle": "💬 提供反馈"
},
"settings": {
"title": "⚙️ 设定",
"description": "调整界面设定和偏好选项。",
@ -79,15 +84,25 @@
"currentLanguage": "当前语言",
"languageDesc": "选择界面显示语言",
"interface": "界面设定",
"combinedMode": "合并模式",
"combinedModeDesc": "将 AI 摘要和回馈输入合并在同一个分页中",
"layoutMode": "界面布局模式",
"layoutModeDesc": "选择 AI 摘要和反馈输入的显示方式",
"separateMode": "分离模式",
"separateModeDesc": "AI 摘要和反馈分别在不同页签",
"combinedVertical": "合并模式(垂直布局)",
"combinedVerticalDesc": "AI 摘要在上,反馈输入在下,摘要和反馈在同一页面",
"combinedHorizontal": "合并模式(水平布局)",
"combinedHorizontalDesc": "AI 摘要在左,反馈输入在右,增大摘要可视区域",
"autoClose": "自动关闭页面",
"autoCloseDesc": "提交回馈后自动关闭页面",
"theme": "主题",
"notifications": "通知",
"advanced": "进阶设定",
"save": "储存设定",
"reset": "重设",
"reset": "重置设定",
"resetDesc": "清除所有已保存的设定,恢复到预设状态",
"resetConfirm": "确定要重置所有设定吗?这将清除所有已保存的偏好设定。",
"resetSuccess": "设定已重置为预设值",
"resetError": "重置设定时发生错误",
"timeout": "连线逾时 (秒)",
"autorefresh": "自动重新整理",
"debug": "除错模式"

View File

@ -72,6 +72,11 @@
"error": "執行錯誤",
"history": "命令歷史"
},
"combined": {
"description": "合併模式AI 摘要和回饋輸入在同一頁面中,方便對照查看。",
"summaryTitle": "📋 AI 工作摘要",
"feedbackTitle": "💬 提供回饋"
},
"settings": {
"title": "⚙️ 設定",
"description": "調整介面設定和偏好選項。",
@ -79,15 +84,25 @@
"currentLanguage": "當前語言",
"languageDesc": "選擇界面顯示語言",
"interface": "介面設定",
"combinedMode": "合併模式",
"combinedModeDesc": "將 AI 摘要和回饋輸入合併在同一個分頁中",
"layoutMode": "界面佈局模式",
"layoutModeDesc": "選擇 AI 摘要和回饋輸入的顯示方式",
"separateMode": "分離模式",
"separateModeDesc": "AI 摘要和回饋分別在不同頁籤",
"combinedVertical": "合併模式(垂直布局)",
"combinedVerticalDesc": "AI 摘要在上,回饋輸入在下,摘要和回饋在同一頁面",
"combinedHorizontal": "合併模式(水平布局)",
"combinedHorizontalDesc": "AI 摘要在左,回饋輸入在右,增大摘要可視區域",
"autoClose": "自動關閉頁面",
"autoCloseDesc": "提交回饋後自動關閉頁面",
"theme": "主題",
"notifications": "通知",
"advanced": "進階設定",
"save": "儲存設定",
"reset": "重設",
"reset": "重置設定",
"resetDesc": "清除所有已保存的設定,恢復到預設狀態",
"resetConfirm": "確定要重置所有設定嗎?這將清除所有已保存的偏好設定。",
"resetSuccess": "設定已重置為預設值",
"resetError": "重置設定時發生錯誤",
"timeout": "連線逾時 (秒)",
"autorefresh": "自動重新整理",
"debug": "除錯模式"

View File

@ -37,7 +37,8 @@ class WebUIManager:
def __init__(self, host: str = "127.0.0.1", port: int = None):
self.host = host
self.port = port or find_free_port()
# 優先使用固定端口 8765確保 localStorage 的一致性
self.port = port or find_free_port(preferred_port=8765)
self.app = FastAPI(title="Interactive Feedback MCP")
self.sessions: Dict[str, WebFeedbackSession] = {}
self.server_thread = None
@ -59,11 +60,8 @@ class WebUIManager:
web_static_path = Path(__file__).parent / "static"
if web_static_path.exists():
self.app.mount("/static", StaticFiles(directory=str(web_static_path)), name="static")
# 備用:原有的靜態文件
fallback_static_path = Path(__file__).parent.parent / "static"
if fallback_static_path.exists():
self.app.mount("/fallback_static", StaticFiles(directory=str(fallback_static_path)), name="fallback_static")
else:
raise RuntimeError(f"Static files directory not found: {web_static_path}")
def _setup_templates(self):
"""設置模板引擎"""
@ -72,9 +70,7 @@ class WebUIManager:
if web_templates_path.exists():
self.templates = Jinja2Templates(directory=str(web_templates_path))
else:
# 備用:原有的模板
fallback_templates_path = Path(__file__).parent.parent / "templates"
self.templates = Jinja2Templates(directory=str(fallback_templates_path))
raise RuntimeError(f"Templates directory not found: {web_templates_path}")
def create_session(self, project_directory: str, summary: str) -> str:
"""創建新的回饋會話"""

View File

@ -107,6 +107,74 @@ def setup_routes(manager: 'WebUIManager'):
finally:
session.websocket = None
@manager.app.post("/api/save-settings")
async def save_settings(request: Request):
"""保存設定到檔案"""
try:
data = await request.json()
# 構建設定檔案路徑
settings_file = Path.cwd() / ".mcp_feedback_settings.json"
# 保存設定到檔案
with open(settings_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
debug_log(f"設定已保存到: {settings_file}")
return JSONResponse(content={"status": "success", "message": "設定已保存"})
except Exception as e:
debug_log(f"保存設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"保存失敗: {str(e)}"}
)
@manager.app.get("/api/load-settings")
async def load_settings():
"""從檔案載入設定"""
try:
settings_file = Path.cwd() / ".mcp_feedback_settings.json"
if settings_file.exists():
with open(settings_file, 'r', encoding='utf-8') as f:
settings = json.load(f)
debug_log(f"設定已從檔案載入: {settings_file}")
return JSONResponse(content=settings)
else:
debug_log("設定檔案不存在,返回空設定")
return JSONResponse(content={})
except Exception as e:
debug_log(f"載入設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"載入失敗: {str(e)}"}
)
@manager.app.post("/api/clear-settings")
async def clear_settings():
"""清除設定檔案"""
try:
settings_file = Path.cwd() / ".mcp_feedback_settings.json"
if settings_file.exists():
settings_file.unlink()
debug_log(f"設定檔案已刪除: {settings_file}")
else:
debug_log("設定檔案不存在,無需刪除")
return JSONResponse(content={"status": "success", "message": "設定已清除"})
except Exception as e:
debug_log(f"清除設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"清除失敗: {str(e)}"}
)
async def handle_websocket_message(manager: 'WebUIManager', session, data: dict):
"""處理 WebSocket 消息"""

View File

@ -5,16 +5,101 @@
* 處理 WebSocket 通信分頁切換圖片上傳命令執行等功能
*/
class PersistentSettings {
constructor() {
this.settingsFile = '.mcp_feedback_settings.json';
this.storageKey = 'mcp_feedback_settings';
}
async saveSettings(settings) {
try {
// 嘗試保存到伺服器端
const response = await fetch('/api/save-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings)
});
if (response.ok) {
console.log('設定已保存到檔案');
} else {
throw new Error('伺服器端保存失敗');
}
} catch (error) {
console.warn('無法保存到檔案,使用 localStorage:', error);
// 備用方案:保存到 localStorage
this.saveToLocalStorage(settings);
}
}
async loadSettings() {
try {
// 嘗試從伺服器端載入
const response = await fetch('/api/load-settings');
if (response.ok) {
const settings = await response.json();
console.log('從檔案載入設定');
return settings;
} else {
throw new Error('伺服器端載入失敗');
}
} catch (error) {
console.warn('無法從檔案載入,使用 localStorage:', error);
// 備用方案:從 localStorage 載入
return this.loadFromLocalStorage();
}
}
saveToLocalStorage(settings) {
localStorage.setItem(this.storageKey, JSON.stringify(settings));
}
loadFromLocalStorage() {
const saved = localStorage.getItem(this.storageKey);
return saved ? JSON.parse(saved) : {};
}
async clearSettings() {
try {
// 清除伺服器端設定
await fetch('/api/clear-settings', { method: 'POST' });
} catch (error) {
console.warn('無法清除伺服器端設定:', error);
}
// 清除 localStorage
localStorage.removeItem(this.storageKey);
// 也清除個別設定項目(向後兼容)
localStorage.removeItem('layoutMode');
localStorage.removeItem('autoClose');
localStorage.removeItem('activeTab');
localStorage.removeItem('language');
}
}
class FeedbackApp {
constructor(sessionId) {
this.sessionId = sessionId;
this.websocket = null;
this.images = [];
this.isConnected = false;
this.combinedMode = false;
this.autoClose = true; // 預設開啟
this.layoutMode = 'separate'; // 預設為分離模式
this.autoClose = true; // 預設啟用自動關閉
this.currentTab = 'feedback'; // 預設當前分頁
this.persistentSettings = new PersistentSettings();
this.images = []; // 初始化圖片陣列
this.isConnected = false; // 初始化連接狀態
this.websocket = null; // 初始化 WebSocket
this.init();
// 立即檢查 DOM 狀態並初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.init();
});
} else {
// DOM 已經載入完成,立即初始化
this.init();
}
}
async init() {
@ -41,8 +126,8 @@ class FeedbackApp {
// 設置鍵盤快捷鍵
this.setupKeyboardShortcuts();
// 載入設定
this.loadSettings();
// 載入設定(使用 await
await this.loadSettings();
// 初始化命令終端
this.initCommandTerminal();
@ -162,41 +247,10 @@ class FeedbackApp {
}
showSuccessMessage() {
// 創建成功訊息提示
const message = document.createElement('div');
message.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--success-color);
color: white;
padding: 12px 20px;
border-radius: 6px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
`;
message.textContent = '✅ 回饋提交成功!';
// 添加動畫樣式
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(style);
document.body.appendChild(message);
// 3秒後移除訊息
setTimeout(() => {
if (message.parentNode) {
message.remove();
}
}, 3000);
const successMessage = window.i18nManager ?
window.i18nManager.t('feedback.success', '✅ 回饋提交成功!') :
'✅ 回饋提交成功!';
this.showMessage(successMessage, 'success');
}
updateConnectionStatus(connected) {
@ -245,8 +299,46 @@ class FeedbackApp {
});
}
// 設置貼上監聽器
this.setupPasteListener();
// 設定切換
this.setupSettingsListeners();
// 設定重置按鈕(如果存在)
const resetSettingsBtn = document.getElementById('resetSettingsBtn');
if (resetSettingsBtn) {
resetSettingsBtn.addEventListener('click', () => this.resetSettings());
}
}
setupSettingsListeners() {
// 設置佈局模式單選按鈕監聽器
const layoutModeRadios = document.querySelectorAll('input[name="layoutMode"]');
layoutModeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
if (e.target.checked) {
this.setLayoutMode(e.target.value);
}
});
});
// 設置自動關閉開關監聽器
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.addEventListener('click', () => {
this.toggleAutoClose();
});
}
// 設置語言選擇器
const languageOptions = document.querySelectorAll('.language-option');
languageOptions.forEach(option => {
option.addEventListener('click', () => {
const lang = option.getAttribute('data-lang');
this.setLanguage(lang);
});
});
}
setupTabs() {
@ -395,31 +487,92 @@ class FeedbackApp {
}
}
setupSettingsListeners() {
// 合併模式開關
const combinedModeToggle = document.getElementById('combinedModeToggle');
if (combinedModeToggle) {
combinedModeToggle.addEventListener('click', () => {
this.toggleCombinedMode();
});
setLayoutMode(mode) {
if (this.layoutMode === mode) return;
this.layoutMode = mode;
// 保存設定到持久化存儲
this.saveSettings();
// 只更新分頁可見性,不強制切換分頁
this.updateTabVisibility();
// 數據同步
if (mode === 'combined-vertical' || mode === 'combined-horizontal') {
// 同步數據到合併模式
this.syncDataToCombinedMode();
} else {
// 切換到分離模式時,同步數據回原始分頁
this.syncDataFromCombinedMode();
}
// 更新合併分頁的佈局樣式
this.updateCombinedModeLayout();
console.log('佈局模式已切換至:', mode);
}
updateTabVisibility() {
const feedbackTab = document.querySelector('[data-tab="feedback"]');
const summaryTab = document.querySelector('[data-tab="summary"]');
const combinedTab = document.querySelector('[data-tab="combined"]');
if (this.layoutMode === 'separate') {
// 分離模式:顯示原本的分頁,隱藏合併分頁
if (feedbackTab) feedbackTab.classList.remove('hidden');
if (summaryTab) summaryTab.classList.remove('hidden');
if (combinedTab) {
combinedTab.classList.add('hidden');
// 只有在當前就在合併分頁時才切換到其他分頁
if (combinedTab.classList.contains('active')) {
this.switchToFeedbackTab();
}
}
} else {
// 合併模式:隱藏原本的分頁,顯示合併分頁
if (feedbackTab) feedbackTab.classList.add('hidden');
if (summaryTab) summaryTab.classList.add('hidden');
if (combinedTab) {
combinedTab.classList.remove('hidden');
// 不要強制切換到合併分頁,讓用戶手動選擇
}
}
}
switchToFeedbackTab() {
// 切換到回饋分頁的輔助方法
const feedbackTab = document.querySelector('[data-tab="feedback"]');
if (feedbackTab) {
// 移除所有分頁按鈕的活躍狀態
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
// 移除所有分頁內容的活躍狀態
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 設定回饋分頁為活躍
feedbackTab.classList.add('active');
document.getElementById('tab-feedback').classList.add('active');
console.log('已切換到回饋分頁');
}
}
updateCombinedModeLayout() {
const combinedTabContent = document.getElementById('tab-combined');
if (!combinedTabContent) {
console.warn('找不到合併分頁元素 #tab-combined');
return;
}
// 自動關閉開關
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.addEventListener('click', () => {
this.toggleAutoClose();
});
}
// 移除所有佈局類
combinedTabContent.classList.remove('combined-horizontal', 'combined-vertical');
// 語言選擇器
const languageOptions = document.querySelectorAll('.language-option');
languageOptions.forEach(option => {
option.addEventListener('click', () => {
const language = option.getAttribute('data-lang');
this.setLanguage(language);
});
});
// 根據當前模式添加對應的佈局類
if (this.layoutMode === 'combined-horizontal') {
combinedTabContent.classList.add('combined-horizontal');
} else if (this.layoutMode === 'combined-vertical') {
combinedTabContent.classList.add('combined-vertical');
}
}
setLanguage(language) {
@ -438,10 +591,11 @@ class FeedbackApp {
// 語言切換後重新處理動態摘要內容
setTimeout(() => {
console.log('語言切換到:', language, '- 重新處理動態內容');
this.processDynamicSummaryContent();
}, 200); // 增加延遲時間確保翻譯加載完成
}
console.log('語言已切換至:', language);
}
handleFileSelection(files) {
@ -598,7 +752,7 @@ class FeedbackApp {
let feedbackText;
// 根據當前模式選擇正確的輸入框
if (this.combinedMode) {
if (this.layoutMode === 'combined-vertical' || this.layoutMode === 'combined-horizontal') {
const combinedFeedbackInput = document.getElementById('combinedFeedbackText');
feedbackText = combinedFeedbackInput?.value.trim() || '';
} else {
@ -650,67 +804,6 @@ class FeedbackApp {
}
}
toggleCombinedMode() {
this.combinedMode = !this.combinedMode;
const toggle = document.getElementById('combinedModeToggle');
if (toggle) {
toggle.classList.toggle('active', this.combinedMode);
}
// 顯示/隱藏分頁
const feedbackTab = document.querySelector('[data-tab="feedback"]');
const summaryTab = document.querySelector('[data-tab="summary"]');
const combinedTab = document.querySelector('[data-tab="combined"]');
if (this.combinedMode) {
// 啟用合併模式:隱藏原本的回饋和摘要分頁,顯示合併分頁
if (feedbackTab) feedbackTab.classList.add('hidden');
if (summaryTab) summaryTab.classList.add('hidden');
if (combinedTab) {
combinedTab.classList.remove('hidden');
// 如果合併分頁顯示,並且當前在回饋或摘要分頁,則將合併分頁設為活躍
const currentActiveTab = document.querySelector('.tab-button.active');
if (currentActiveTab && (currentActiveTab.getAttribute('data-tab') === 'feedback' || currentActiveTab.getAttribute('data-tab') === 'summary')) {
combinedTab.classList.add('active');
currentActiveTab.classList.remove('active');
// 顯示對應的分頁內容
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById('tab-combined').classList.add('active');
}
}
// 同步數據到合併模式
this.syncDataToCombinedMode();
} else {
// 停用合併模式:顯示原本的分頁,隱藏合併分頁
if (feedbackTab) feedbackTab.classList.remove('hidden');
if (summaryTab) summaryTab.classList.remove('hidden');
if (combinedTab) {
combinedTab.classList.add('hidden');
// 如果當前在合併分頁,則切換到回饋分頁
if (combinedTab.classList.contains('active')) {
combinedTab.classList.remove('active');
if (feedbackTab) {
feedbackTab.classList.add('active');
// 顯示對應的分頁內容
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById('tab-feedback').classList.add('active');
}
}
}
// 同步數據回原始分頁
this.syncDataFromCombinedMode();
}
localStorage.setItem('combinedMode', this.combinedMode.toString());
console.log('合併模式已', this.combinedMode ? '啟用' : '停用');
}
toggleAutoClose() {
this.autoClose = !this.autoClose;
@ -719,7 +812,8 @@ class FeedbackApp {
toggle.classList.toggle('active', this.autoClose);
}
localStorage.setItem('autoClose', this.autoClose.toString());
// 保存設定到持久化存儲
this.saveSettings();
console.log('自動關閉頁面已', this.autoClose ? '啟用' : '停用');
}
@ -749,51 +843,107 @@ class FeedbackApp {
}
}
loadSettings() {
// 載入合併模式設定
const savedCombinedMode = localStorage.getItem('combinedMode');
if (savedCombinedMode === 'true') {
this.combinedMode = true;
const toggle = document.getElementById('combinedModeToggle');
if (toggle) {
toggle.classList.add('active');
syncLanguageSelector() {
// 同步語言選擇器的狀態
if (window.i18nManager) {
const currentLang = window.i18nManager.currentLanguage;
// 更新現代化語言選擇器
const languageOptions = document.querySelectorAll('.language-option');
languageOptions.forEach(option => {
const lang = option.getAttribute('data-lang');
option.classList.toggle('active', lang === currentLang);
});
}
}
async loadSettings() {
try {
// 使用持久化設定系統載入設定
const settings = await this.persistentSettings.loadSettings();
// 載入佈局模式設定
if (settings.layoutMode && ['separate', 'combined-vertical', 'combined-horizontal'].includes(settings.layoutMode)) {
this.layoutMode = settings.layoutMode;
} else {
// 嘗試從舊的 localStorage 載入(向後兼容)
const savedLayoutMode = localStorage.getItem('layoutMode');
if (savedLayoutMode && ['separate', 'combined-vertical', 'combined-horizontal'].includes(savedLayoutMode)) {
this.layoutMode = savedLayoutMode;
} else {
this.layoutMode = 'separate'; // 預設為分離模式
}
}
// 應用合併模式設定
this.applyCombinedModeState();
}
// 更新佈局模式單選按鈕狀態
const layoutRadios = document.querySelectorAll('input[name="layoutMode"]');
layoutRadios.forEach((radio, index) => {
radio.checked = radio.value === this.layoutMode;
});
// 載入自動關閉設定
const savedAutoClose = localStorage.getItem('autoClose');
if (savedAutoClose !== null) {
this.autoClose = savedAutoClose === 'true';
} else {
// 如果沒有保存的設定使用預設值true
// 載入自動關閉設定
if (settings.autoClose !== undefined) {
this.autoClose = settings.autoClose;
} else {
// 嘗試從舊的 localStorage 載入(向後兼容)
const savedAutoClose = localStorage.getItem('autoClose');
if (savedAutoClose !== null) {
this.autoClose = savedAutoClose === 'true';
} else {
this.autoClose = true; // 預設啟用
}
}
// 更新自動關閉開關狀態
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.classList.toggle('active', this.autoClose);
}
// 確保語言選擇器與當前語言同步
this.syncLanguageSelector();
// 應用佈局模式設定
this.applyCombinedModeState();
// 如果是合併模式,同步數據
if (this.layoutMode === 'combined-vertical' || this.layoutMode === 'combined-horizontal') {
this.syncDataToCombinedMode();
}
console.log('設定已載入:', {
layoutMode: this.layoutMode,
autoClose: this.autoClose,
currentLanguage: window.i18nManager?.currentLanguage,
source: settings.layoutMode ? 'persistent' : 'localStorage'
});
} catch (error) {
console.warn('載入設定時發生錯誤:', error);
// 使用預設設定
this.layoutMode = 'separate';
this.autoClose = true;
}
// 更新自動關閉開關狀態
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.classList.toggle('active', this.autoClose);
// 仍然需要更新 UI 狀態
const layoutRadios = document.querySelectorAll('input[name="layoutMode"]');
layoutRadios.forEach((radio, index) => {
radio.checked = radio.value === this.layoutMode;
});
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.classList.toggle('active', this.autoClose);
}
}
}
applyCombinedModeState() {
const feedbackTab = document.querySelector('[data-tab="feedback"]');
const summaryTab = document.querySelector('[data-tab="summary"]');
const combinedTab = document.querySelector('[data-tab="combined"]');
if (this.combinedMode) {
// 隱藏原本的回饋和摘要分頁,顯示合併分頁
if (feedbackTab) feedbackTab.classList.add('hidden');
if (summaryTab) summaryTab.classList.add('hidden');
if (combinedTab) combinedTab.classList.remove('hidden');
} else {
// 顯示原本的分頁,隱藏合併分頁
if (feedbackTab) feedbackTab.classList.remove('hidden');
if (summaryTab) summaryTab.classList.remove('hidden');
if (combinedTab) combinedTab.classList.add('hidden');
// 更新分頁可見性
this.updateTabVisibility();
// 更新合併分頁的佈局樣式
if (this.layoutMode !== 'separate') {
this.updateCombinedModeLayout();
}
}
@ -818,6 +968,136 @@ Supported commands: ls, dir, pwd, cat, type, etc.
$ `;
this.appendCommandOutput(welcomeMessage);
}
async resetSettings() {
// 確認重置
const confirmMessage = window.i18nManager ?
window.i18nManager.t('settings.resetConfirm', '確定要重置所有設定嗎?這將清除所有已保存的偏好設定。') :
'確定要重置所有設定嗎?這將清除所有已保存的偏好設定。';
if (!confirm(confirmMessage)) {
return;
}
try {
// 使用持久化設定系統清除設定
await this.persistentSettings.clearSettings();
// 重置本地變數
this.layoutMode = 'separate';
this.autoClose = true;
// 更新佈局模式單選按鈕狀態
const layoutRadios = document.querySelectorAll('input[name="layoutMode"]');
layoutRadios.forEach((radio, index) => {
radio.checked = radio.value === this.layoutMode;
});
// 更新自動關閉開關狀態
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.classList.toggle('active', this.autoClose);
}
// 確保語言選擇器與當前語言同步
this.syncLanguageSelector();
// 應用佈局模式設定
this.applyCombinedModeState();
// 切換到回饋分頁
this.switchToFeedbackTab();
// 顯示成功訊息
const successMessage = window.i18nManager ?
window.i18nManager.t('settings.resetSuccess', '設定已重置為預設值') :
'設定已重置為預設值';
this.showMessage(successMessage, 'success');
console.log('設定已重置');
} catch (error) {
console.error('重置設定時發生錯誤:', error);
// 顯示錯誤訊息
const errorMessage = window.i18nManager ?
window.i18nManager.t('settings.resetError', '重置設定時發生錯誤') :
'重置設定時發生錯誤';
this.showMessage(errorMessage, 'error');
}
}
showMessage(text, type = 'info') {
// 確保動畫樣式已添加
if (!document.getElementById('slideInAnimation')) {
const style = document.createElement('style');
style.id = 'slideInAnimation';
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(style);
}
// 創建訊息提示
const message = document.createElement('div');
const colors = {
success: 'var(--success-color)',
error: 'var(--error-color)',
warning: 'var(--warning-color)',
info: 'var(--info-color)'
};
message.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors[type] || colors.info};
color: white;
padding: 12px 20px;
border-radius: 6px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
`;
message.textContent = text;
document.body.appendChild(message);
// 3秒後移除訊息
setTimeout(() => {
if (message.parentNode) {
message.remove();
}
}, 3000);
}
async saveSettings() {
try {
const settings = {
layoutMode: this.layoutMode,
autoClose: this.autoClose,
language: window.i18nManager?.currentLanguage || 'zh-TW',
activeTab: localStorage.getItem('activeTab'),
lastSaved: new Date().toISOString()
};
await this.persistentSettings.saveSettings(settings);
// 同時保存到 localStorage 作為備用(向後兼容)
localStorage.setItem('layoutMode', this.layoutMode);
localStorage.setItem('autoClose', this.autoClose.toString());
console.log('設定已保存:', settings);
} catch (error) {
console.warn('保存設定時發生錯誤:', error);
}
}
}
// 全域函數,供 HTML 中的 onclick 使用

View File

@ -434,10 +434,6 @@
flex-wrap: wrap;
}
.footer-actions {
flex-direction: column-reverse;
}
/* 小屏幕下調整命令輸出區域高度 */
.command-output {
height: 250px;
@ -657,6 +653,151 @@
padding-bottom: 8px;
border-bottom: 2px solid var(--accent-color);
}
/* 新增:水平佈局的 CSS 樣式 */
.layout-mode-selector {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.layout-option {
display: flex;
align-items: flex-start;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-tertiary);
transition: all 0.3s ease;
cursor: pointer;
}
.layout-option:hover {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.1);
}
.layout-option input[type="radio"] {
margin: 0;
margin-right: 12px;
margin-top: 2px;
accent-color: var(--accent-color);
transform: scale(1.2);
}
.layout-option label {
flex: 1;
cursor: pointer;
}
.layout-option-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.layout-option-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.layout-option input[type="radio"]:checked + label {
color: var(--accent-color);
}
.layout-option input[type="radio"]:checked + label .layout-option-title {
color: var(--accent-color);
}
.layout-option:has(input[type="radio"]:checked) {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.15);
}
/* 合併模式分頁的水平佈局樣式 */
#tab-combined.active.combined-horizontal .combined-content {
display: flex !important;
flex-direction: row !important;
gap: 16px;
height: calc(100% - 60px); /* 減去描述區塊的高度 */
}
#tab-combined.active.combined-horizontal .combined-section:first-child {
flex: 1 !important;
min-width: 300px;
max-width: 50%;
overflow: hidden; /* 確保容器不超出範圍 */
}
#tab-combined.active.combined-horizontal .combined-section:last-child {
flex: 1 !important;
min-width: 400px;
}
#tab-combined.active.combined-horizontal .combined-summary {
height: calc(100vh - 200px);
max-height: 600px;
overflow: hidden; /* 確保摘要容器不超出範圍 */
}
#tab-combined.active.combined-horizontal #combinedSummaryContent {
height: 100%;
min-height: 400px;
overflow-y: auto; /* 添加垂直滾動條 */
overflow-x: hidden; /* 隱藏水平滾動條 */
}
#tab-combined.active.combined-horizontal .text-input {
min-height: 200px;
}
/* 合併模式分頁的垂直佈局樣式 */
#tab-combined.active.combined-vertical .combined-content {
display: flex !important;
flex-direction: column !important;
gap: 16px;
height: calc(100% - 60px); /* 減去描述區塊的高度 */
}
#tab-combined.active.combined-vertical .combined-section:first-child {
flex: 1 !important;
min-height: 200px;
max-height: 400px;
overflow: hidden; /* 確保容器不超出範圍 */
}
#tab-combined.active.combined-vertical .combined-section:last-child {
flex: 2 !important;
min-height: 300px;
}
#tab-combined.active.combined-vertical .combined-summary {
height: 300px;
max-height: 400px;
overflow: hidden; /* 確保摘要容器不超出範圍 */
}
#tab-combined.active.combined-vertical #combinedSummaryContent {
height: 100%;
min-height: 200px;
overflow-y: auto; /* 添加垂直滾動條 */
overflow-x: hidden; /* 隱藏水平滾動條 */
}
#tab-combined.active.combined-vertical .text-input {
min-height: 200px;
}
/* 預設的合併內容布局 */
.combined-content {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
</style>
</head>
<body>
@ -778,6 +919,53 @@
</div>
</div>
<!-- 合併模式分頁 - 移動到此位置 -->
<div id="tab-combined" class="tab-content">
<div class="section-description" style="margin-bottom: 12px; padding: 8px 12px; font-size: 13px;" data-i18n="combined.description">
合併模式AI 摘要和回饋輸入在同一頁面中,方便對照查看。
</div>
<div class="combined-content">
<!-- AI 摘要區域 -->
<div class="combined-section">
<h3 class="combined-section-title" data-i18n="combined.summaryTitle">📋 AI 工作摘要</h3>
<div class="combined-summary">
<div id="combinedSummaryContent" class="text-input" style="min-height: 200px; white-space: pre-wrap; cursor: text;" data-dynamic-content="aiSummary">
{{ summary }}
</div>
</div>
</div>
<!-- 回饋輸入區域 -->
<div class="combined-section">
<h3 class="combined-section-title" data-i18n="combined.feedbackTitle">💬 提供回饋</h3>
<div class="input-group">
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
<textarea
id="combinedFeedbackText"
class="text-input"
data-i18n-placeholder="feedback.detailedPlaceholder"
placeholder="請在這裡輸入您的回饋..."
style="min-height: 150px;"
></textarea>
</div>
<div class="input-group">
<label class="input-label" data-i18n="feedback.imageLabel">圖片附件(可選)</label>
<div id="combinedImageUploadArea" class="image-upload-area" style="min-height: 100px;">
<div id="combinedImageUploadText" data-i18n="feedback.imageUploadText">
📎 點擊選擇圖片或拖放圖片到此處<br>
<small>支援 PNG、JPG、JPEG、GIF、BMP、WebP 等格式</small>
</div>
<div id="combinedImagePreviewContainer" class="image-preview-container"></div>
<input type="file" id="combinedImageInput" multiple accept="image/*" style="display: none;">
</div>
</div>
</div>
</div>
</div>
<!-- 設定分頁 -->
<div id="tab-settings" class="tab-content">
<div class="section-description" data-i18n="settings.description">
@ -792,13 +980,33 @@
<div class="settings-card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.combinedMode">合併模式</div>
<div class="setting-description" data-i18n="settings.combinedModeDesc">
將 AI 摘要和回饋輸入合併在同一個分頁中
<div class="setting-label" data-i18n="settings.layoutMode">界面佈局模式</div>
<div class="setting-description" data-i18n="settings.layoutModeDesc">
選擇 AI 摘要和回饋輸入的顯示方式
</div>
</div>
<div id="combinedModeToggle" class="toggle-switch">
<div class="toggle-knob"></div>
<div class="layout-mode-selector">
<div class="layout-option">
<input type="radio" id="separateMode" name="layoutMode" value="separate" checked>
<label for="separateMode">
<div class="layout-option-title" data-i18n="settings.separateMode">分離模式</div>
<div class="layout-option-desc" data-i18n="settings.separateModeDesc">AI 摘要和回饋分別在不同頁籤</div>
</label>
</div>
<div class="layout-option">
<input type="radio" id="combinedVertical" name="layoutMode" value="combined-vertical">
<label for="combinedVertical">
<div class="layout-option-title" data-i18n="settings.combinedVertical">合併模式(垂直布局)</div>
<div class="layout-option-desc" data-i18n="settings.combinedVerticalDesc">AI 摘要在上,回饋輸入在下,摘要和回饋在同一頁面</div>
</label>
</div>
<div class="layout-option">
<input type="radio" id="combinedHorizontal" name="layoutMode" value="combined-horizontal">
<label for="combinedHorizontal">
<div class="layout-option-title" data-i18n="settings.combinedHorizontal">合併模式(水平布局)</div>
<div class="layout-option-desc" data-i18n="settings.combinedHorizontalDesc">AI 摘要在左,回饋輸入在右,增大摘要可視區域</div>
</label>
</div>
</div>
</div>
<div class="setting-item">
@ -847,48 +1055,23 @@
</div>
</div>
</div>
</div>
<!-- 合併模式分頁 - 新增 -->
<div id="tab-combined" class="tab-content">
<div class="section-description">
合併模式AI 摘要和回饋輸入在同一頁面中,方便對照查看。
</div>
<!-- AI 摘要區域 -->
<div class="combined-section">
<h3 class="combined-section-title">📋 AI 工作摘要</h3>
<div class="combined-summary">
<div id="combinedSummaryContent" class="text-input" style="min-height: 200px; white-space: pre-wrap; cursor: text;" data-dynamic-content="aiSummary">
{{ summary }}
</div>
<!-- 重置設定卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<h3 class="settings-card-title" data-i18n="settings.advanced">🔧 進階設定</h3>
</div>
</div>
<!-- 回饋輸入區域 -->
<div class="combined-section">
<h3 class="combined-section-title">💬 提供回饋</h3>
<div class="input-group">
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
<textarea
id="combinedFeedbackText"
class="text-input"
data-i18n-placeholder="feedback.detailedPlaceholder"
placeholder="請在這裡輸入您的回饋..."
style="min-height: 150px;"
></textarea>
</div>
<div class="input-group">
<label class="input-label" data-i18n="feedback.imageLabel">圖片附件(可選)</label>
<div id="combinedImageUploadArea" class="image-upload-area" style="min-height: 100px;">
<div id="combinedImageUploadText" data-i18n="feedback.imageUploadText">
📎 點擊選擇圖片或拖放圖片到此處<br>
<small>支援 PNG、JPG、JPEG、GIF、BMP、WebP 等格式</small>
<div class="settings-card-body">
<div class="setting-item" style="border-bottom: none;">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.reset">重置設定</div>
<div class="setting-description" data-i18n="settings.resetDesc">
清除所有已保存的設定,恢復到預設狀態
</div>
</div>
<div id="combinedImagePreviewContainer" class="image-preview-container"></div>
<input type="file" id="combinedImageInput" multiple accept="image/*" style="display: none;">
<button id="resetSettingsBtn" class="btn btn-secondary" style="font-size: 12px; padding: 6px 16px;">
<span data-i18n="settings.reset">重置設定</span>
</button>
</div>
</div>
</div>
@ -992,12 +1175,9 @@
<script src="/static/js/i18n.js"></script>
<script src="/static/js/app.js"></script>
<script>
// 初始化頁面
document.addEventListener('DOMContentLoaded', function() {
// 初始化 WebSocket 連接
const sessionId = '{{ session_id }}';
window.feedbackApp = new FeedbackApp(sessionId);
});
// 初始化全域應用程式實例
const sessionId = '{{ session_id }}';
window.feedbackApp = new FeedbackApp(sessionId);
</script>
</body>
</html>

View File

@ -11,13 +11,14 @@ import socket
from typing import Optional
def find_free_port(start_port: int = 8765, max_attempts: int = 100) -> int:
def find_free_port(start_port: int = 8765, max_attempts: int = 100, preferred_port: int = 8765) -> int:
"""
尋找可用的端口
尋找可用的端口優先使用偏好端口
Args:
start_port: 起始端口號
max_attempts: 最大嘗試次數
preferred_port: 偏好端口號用於保持設定持久性
Returns:
int: 可用的端口號
@ -25,8 +26,15 @@ def find_free_port(start_port: int = 8765, max_attempts: int = 100) -> int:
Raises:
RuntimeError: 如果找不到可用端口
"""
# 首先嘗試偏好端口(通常是 8765
if is_port_available("127.0.0.1", preferred_port):
return preferred_port
# 如果偏好端口不可用,嘗試其他端口
for i in range(max_attempts):
port = start_port + i
if port == preferred_port: # 跳過已經嘗試過的偏好端口
continue
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", port))