diff --git a/data/users/demo.sqlite-shm b/data/users/demo.sqlite-shm index a95cd29..3b9724d 100644 Binary files a/data/users/demo.sqlite-shm and b/data/users/demo.sqlite-shm differ diff --git a/docs/MARKBASE_SEARCH_SESSION_API.md b/docs/MARKBASE_SEARCH_SESSION_API.md new file mode 100644 index 0000000..bd72819 --- /dev/null +++ b/docs/MARKBASE_SEARCH_SESSION_API.md @@ -0,0 +1,344 @@ +# MarkBase Search Session API 需求 + +## 背景 +Momentry Studio 需要將搜尋歷史(Search History)升級為以 Session 為單位的管理系統,並整合到 MarkBase 的 SQLite 資料庫中。目前 Studio 使用本地 SQLite 儲存搜尋記錄,現在需要改為透過 MarkBase API 存取。 + +--- + +## 1. SQLite Schema 需求 + +### 1.1 新增 search_sessions 表 + +```sql +CREATE TABLE IF NOT EXISTS search_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '未命名搜尋', + mode TEXT DEFAULT 'keyword', -- keyword | visual | people | agent + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX idx_sessions_user ON search_sessions(user_id); +CREATE INDEX idx_sessions_updated ON search_sessions(user_id, updated_at DESC); +``` + +### 1.2 新增 search_messages 表 + +```sql +CREATE TABLE IF NOT EXISTS search_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES search_sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL, -- 'user' | 'assistant' + content TEXT, -- 使用者輸入或 AI 回答 + results TEXT, -- JSON 格式的搜尋結果 + mode TEXT DEFAULT 'keyword', -- 搜尋模式 + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE INDEX idx_messages_session ON search_messages(session_id, created_at); +``` + +### 1.3 修改現有 search_history 表(選用) + +如果要保留向後相容性: +```sql +ALTER TABLE search_history ADD COLUMN session_id TEXT REFERENCES search_sessions(id); +``` + +--- + +## 2. API 端點需求 + +所有端點前綴:`/api/v2/search-sessions` + +### 2.1 列出 Session + +**端點**: `GET /api/v2/search-sessions` + +**認證**: 需要 Bearer Token + +**查詢參數**: +| 參數 | 類型 | 必填 | 說明 | 預設 | +|---|---|---|---|---| +| limit | number | ❌ | 返回數量 | 30 | +| offset | number | ❌ | 偏移量 | 0 | + +**回應**: +```json +{ + "success": true, + "total": 15, + "sessions": [ + { + "id": "sess_abc123", + "title": "廚房場景搜尋", + "mode": "visual", + "message_count": 5, + "created_at": "2026-10-27T10:00:00Z", + "updated_at": "2026-10-27T10:30:00Z" + } + ] +} +``` + +--- + +### 2.2 建立 Session + +**端點**: `POST /api/v2/search-sessions` + +**認證**: 需要 Bearer Token + +**請求**: +```json +{ + "title": "廚房場景搜尋", + "mode": "visual" +} +``` + +**回應**: +```json +{ + "success": true, + "session": { + "id": "sess_abc123", + "title": "廚房場景搜尋", + "mode": "visual", + "created_at": "2026-10-27T10:00:00Z", + "updated_at": "2026-10-27T10:00:00Z" + } +} +``` + +--- + +### 2.3 更新 Session + +**端點**: `PUT /api/v2/search-sessions/:id` + +**認證**: 需要 Bearer Token + +**請求**: +```json +{ + "title": "重新命名的搜尋" +} +``` + +**回應**: +```json +{ + "success": true, + "message": "Session updated" +} +``` + +--- + +### 2.4 刪除 Session + +**端點**: `DELETE /api/v2/search-sessions/:id` + +**認證**: 需要 Bearer Token + +**回應**: +```json +{ + "success": true, + "message": "Session deleted" +} +``` + +--- + +### 2.5 取得 Session 對話 + +**端點**: `GET /api/v2/search-sessions/:id/messages` + +**認證**: 需要 Bearer Token + +**回應**: +```json +{ + "success": true, + "messages": [ + { + "id": "msg_001", + "role": "user", + "content": "Find kitchen scenes", + "results": null, + "created_at": "2026-10-27T10:00:00Z" + }, + { + "id": "msg_002", + "role": "assistant", + "content": "Found 3 kitchen scenes...", + "results": "[...]", + "created_at": "2026-10-27T10:00:01Z" + } + ] +} +``` + +--- + +### 2.6 新增訊息到 Session + +**端點**: `POST /api/v2/search-sessions/:id/messages` + +**認證**: 需要 Bearer Token + +**請求**: +```json +{ + "role": "user", + "content": "Find kitchen scenes", + "mode": "visual", + "results": null +} +``` + +**回應**: +```json +{ + "success": true, + "message": { + "id": "msg_001", + "role": "user", + "content": "Find kitchen scenes", + "created_at": "2026-10-27T10:00:00Z" + } +} +``` + +--- + +### 2.7 更新訊息結果(AI 回答後) + +**端點**: `PATCH /api/v2/search-sessions/:id/messages/:msgId` + +**認證**: 需要 Bearer Token + +**請求**: +```json +{ + "role": "assistant", + "content": "Found 3 kitchen scenes...", + "results": [{...}, {...}] +} +``` + +**回應**: +```json +{ + "success": true, + "message": "Message updated" +} +``` + +--- + +## 3. 業務邏輯需求 + +### 3.1 自動清理 +- 未命名的 Session 超過 24 小時自動刪除 +- 每個使用者最多保留 50 個 Session +- 超出限制時,刪除最舊的未命名 Session + +### 3.2 使用者隔離 +- 所有查詢自動過濾 `user_id` +- Token 中必須包含 `user_id` 或從 token 解析 + +### 3.3 預設 Session +- 使用者首次進入 Search 頁面時,自動建立 "Default" Session +- 後續搜尋都歸入目前活躍的 Session + +--- + +## 4. 錯誤處理 + +### 統一錯誤格式 +```json +{ + "success": false, + "error": "Session not found", + "code": "SESSION_NOT_FOUND" +} +``` + +### 錯誤代碼 +| 代碼 | HTTP 狀態 | 說明 | +|---|---|---| +| SESSION_NOT_FOUND | 404 | Session 不存在 | +| UNAUTHORIZED | 401 | 無效 Token | +| VALIDATION_ERROR | 400 | 請求格式錯誤 | +| LIMIT_EXCEEDED | 429 | 超過 Session 數量限制 | + +--- + +## 5. 前端整合計劃 + +### 5.1 Studio 修改項目 +| 檔案 | 修改內容 | +|---|---| +| `src/composables/useSearchSession.ts` | 新增 Session 管理 composable | +| `src/views/SearchView.vue` | 側邊欄改為 Session 列表 | +| `src/api/index.ts` | 新增 search-session 相關 HTTP 呼叫 | + +### 5.2 移除項目 +| 檔案 | 移除內容 | +|---|---| +| `src-tauri/src/db.rs` | search_history 相關函數 | +| `src-tauri/src/proxy.rs` | search-history handler | +| `src-tauri/src/main.rs` | search_history Tauri commands | + +--- + +## 6. 測試清單 + +### 6.1 API 測試 +- [ ] 建立 Session +- [ ] 列出 Sessions(分頁) +- [ ] 更新 Session 標題 +- [ ] 刪除 Session +- [ ] 新增訊息 +- [ ] 更新訊息結果 +- [ ] 取得 Session 對話 +- [ ] 使用者隔離測試 +- [ ] 自動清理測試 + +### 6.2 前端測試 +- [ ] 新 Session 建立 +- [ ] Session 命名 +- [ ] 切換 Session +- [ ] 刪除 Session +- [ ] 對話還原 +- [ ] 自動建立 Default Session + +--- + +## 7. 優先順序 + +| 優先級 | 任務 | 說明 | +|---|---|---| +| **P0** | SQLite Schema | search_sessions + search_messages 表 | +| **P0** | CRUD API | Session 增刪改查 | +| **P0** | Messages API | 訊息增刪改查 | +| **P1** | 自動清理 | 24 小時 + 50 筆限制 | +| **P1** | 使用者隔離 | Token 解析 + user_id 過濾 | +| **P2** | 效能優化 | 索引 + 分頁 | + +--- + +## 8. 時間預估 + +| 階段 | 預估工時 | +|---|---| +| Schema 設計 + 實作 | 30 min | +| CRUD API 實作 | 60 min | +| Messages API 實作 | 45 min | +| 自動清理邏輯 | 30 min | +| 測試 + 文檔 | 30 min | +| **總計** | **~3 小時** | diff --git a/docs/STUDIO_INTEGRATION_PLAN.md b/docs/STUDIO_INTEGRATION_PLAN.md new file mode 100644 index 0000000..b9e625c --- /dev/null +++ b/docs/STUDIO_INTEGRATION_PLAN.md @@ -0,0 +1,1216 @@ +# Momentry Studio 整合計劃 + +**日期**:2026-10-27 +**目標**:將 Search Session API 整合到 Momentry Studio 前端 +**預計工作量**:2-3 小時(Studio 團隊) +**前置條件**:✅ Search Session API 已實施並部署 + +--- + +## 📋 整合步驟 + +### Phase 1:前端基礎設施(30 分鐘) + +#### 1.1 創建 API Service 層 + +**文件**:`src/api/searchSession.ts`(新建) + +```typescript +// src/api/searchSession.ts +import type { SearchSession, SearchMessage } from '@/types/search'; + +const API_BASE = import.meta.env.VITE_MARKBASE_URL || 'http://localhost:11438'; + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + code?: string; +} + +export class SearchSessionApi { + private token: string; + + constructor(token: string) { + this.token = token; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise> { + const response = await fetch(`${API_BASE}${endpoint}`, { + ...options, + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json(); + return { + success: false, + error: error.error || 'Request failed', + code: error.code, + }; + } + + const data = await response.json(); + return { success: true, data }; + } + + // Session CRUD + async listSessions(limit = 30, offset = 0): Promise> { + return this.request(`/api/v2/search-sessions?limit=${limit}&offset=${offset}`); + } + + async createSession(title: string, mode: string): Promise> { + return this.request('/api/v2/search-sessions', { + method: 'POST', + body: JSON.stringify({ title, mode }), + }); + } + + async updateSession(id: string, title: string): Promise> { + return this.request(`/api/v2/search-sessions/${id}`, { + method: 'PUT', + body: JSON.stringify({ title }), + }); + } + + async deleteSession(id: string): Promise> { + return this.request(`/api/v2/search-sessions/${id}`, { + method: 'DELETE', + }); + } + + // Messages + async listMessages(sessionId: string): Promise> { + return this.request(`/api/v2/search-sessions/${sessionId}/messages`); + } + + async createMessage( + sessionId: string, + role: 'user' | 'assistant', + content: string, + mode: string, + results?: string + ): Promise> { + return this.request(`/api/v2/search-sessions/${sessionId}/messages`, { + method: 'POST', + body: JSON.stringify({ role, content, mode, results: results || '' }), + }); + } + + async updateMessage( + sessionId: string, + messageId: string, + updates: Partial> + ): Promise> { + return this.request(`/api/v2/search-sessions/${sessionId}/messages/${messageId}`, { + method: 'PATCH', + body: JSON.stringify(updates), + }); + } +} +``` + +--- + +#### 1.2 定義 TypeScript 類型 + +**文件**:`src/types/search.ts`(新建) + +```typescript +// src/types/search.ts +export interface SearchSession { + id: string; + title: string; + mode: 'keyword' | 'visual' | 'semantic'; + message_count: number; + created_at: string; + updated_at: string; +} + +export interface SearchMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + results: string; // JSON string + mode: 'keyword' | 'visual' | 'semantic'; + created_at: string; +} + +export interface SearchSessionList { + sessions: SearchSession[]; + total: number; +} + +export interface SearchConversation { + session: SearchSession; + messages: SearchMessage[]; +} +``` + +--- + +### Phase 2:Composable 層(40 分鐘) + +#### 2.1 創建 useSearchSession Composable + +**文件**:`src/composables/useSearchSession.ts`(新建) + +```typescript +// src/composables/useSearchSession.ts +import { ref, computed, onMounted } from 'vue'; +import { SearchSessionApi } from '@/api/searchSession'; +import type { SearchSession, SearchMessage } from '@/types/search'; + +export function useSearchSession() { + const token = localStorage.getItem('markbase_token') || ''; + const api = new SearchSessionApi(token); + + // State + const sessions = ref([]); + const currentSession = ref(null); + const messages = ref([]); + const isLoading = ref(false); + const error = ref(null); + + // Computed + const hasCurrentSession = computed(() => currentSession.value !== null); + const totalSessions = computed(() => sessions.value.length); + + // Actions + async function loadSessions() { + isLoading.value = true; + error.value = null; + + const response = await api.listSessions(); + + if (response.success && response.data) { + sessions.value = response.data.sessions; + } else { + error.value = response.error || 'Failed to load sessions'; + } + + isLoading.value = false; + } + + async function createNewSession(title: string, mode: 'keyword' | 'visual' | 'semantic' = 'keyword') { + isLoading.value = true; + error.value = null; + + const response = await api.createSession(title, mode); + + if (response.success && response.data) { + sessions.value.unshift(response.data.session); + currentSession.value = response.data.session; + messages.value = []; + } else { + error.value = response.error || 'Failed to create session'; + } + + isLoading.value = false; + } + + async function loadSession(sessionId: string) { + isLoading.value = true; + error.value = null; + + // Load session details + const session = sessions.value.find(s => s.id === sessionId); + if (session) { + currentSession.value = session; + } + + // Load messages + const response = await api.listMessages(sessionId); + + if (response.success && response.data) { + messages.value = response.data.messages; + } else { + error.value = response.error || 'Failed to load messages'; + } + + isLoading.value = false; + } + + async function sendMessage(content: string, mode: 'keyword' | 'visual' | 'semantic' = 'keyword') { + if (!currentSession.value) { + error.value = 'No active session'; + return; + } + + isLoading.value = true; + error.value = null; + + // Add user message + const userMsgResponse = await api.createMessage( + currentSession.value.id, + 'user', + content, + mode + ); + + if (!userMsgResponse.success) { + error.value = userMsgResponse.error || 'Failed to send message'; + isLoading.value = false; + return; + } + + if (userMsgResponse.data) { + messages.value.push(userMsgResponse.data.message); + } + + // TODO: Call search backend and get results + // For now, simulate assistant response + const assistantMsgResponse = await api.createMessage( + currentSession.value.id, + 'assistant', + 'Search results will appear here...', + mode, + JSON.stringify([{ file: 'example.txt', score: 0.95 }]) + ); + + if (assistantMsgResponse.success && assistantMsgResponse.data) { + messages.value.push(assistantMsgResponse.data.message); + } + + isLoading.value = false; + } + + async function updateSessionTitle(sessionId: string, title: string) { + const response = await api.updateSession(sessionId, title); + + if (response.success) { + const session = sessions.value.find(s => s.id === sessionId); + if (session) { + session.title = title; + } + } else { + error.value = response.error || 'Failed to update session'; + } + } + + async function deleteSession(sessionId: string) { + const response = await api.deleteSession(sessionId); + + if (response.success) { + sessions.value = sessions.value.filter(s => s.id !== sessionId); + if (currentSession.value?.id === sessionId) { + currentSession.value = null; + messages.value = []; + } + } else { + error.value = response.error || 'Failed to delete session'; + } + } + + // Auto-load sessions on mount + onMounted(() => { + loadSessions(); + }); + + return { + // State + sessions, + currentSession, + messages, + isLoading, + error, + + // Computed + hasCurrentSession, + totalSessions, + + // Actions + loadSessions, + createNewSession, + loadSession, + sendMessage, + updateSessionTitle, + deleteSession, + }; +} +``` + +--- + +### Phase 3:UI 組件(60 分鐘) + +#### 3.1 創建 SessionList 組件 + +**文件**:`src/components/search/SessionList.vue`(新建) + +```vue + + + + + + +``` + +--- + +#### 3.2 創建 SessionItem 組件 + +**文件**:`src/components/search/SessionItem.vue`(新建) + +```vue + + + + + + +``` + +--- + +#### 3.3 創建 ChatArea 組件 + +**文件**:`src/components/search/ChatArea.vue`(新建) + +```vue + + + + + + +``` + +--- + +#### 3.4 創建 MessageItem 組件 + +**文件**:`src/components/search/MessageItem.vue`(新建) + +```vue + + + + + + +``` + +--- + +### Phase 4:視圖整合(30 分鐘) + +#### 4.1 修改 SearchView.vue + +**文件**:`src/views/SearchView.vue`(修改) + +```vue + + + + + + +``` + +--- + +### Phase 5:移除舊代碼(20 分鐘) + +#### 5.1 移除 search_history 相關代碼 + +**文件清單**: + +1. **src-tauri/src/db.rs**: + - 移除 `search_history` 表相關函數 + - 移除 `SearchHistoryItem` 結構體 + +2. **src-tauri/src/proxy.rs**: + - 移除 `search-history` handler + +3. **src-tauri/src/main.rs**: + - 移除 `search-history` command 註冊 + +**示例**: + +```rust +// 移除前 +#[tauri::command] +fn get_search_history() -> Vec { + // ... +} + +// 移除後 +// (刪除整個函數) +``` + +--- + +### Phase 6:測試與驗證(30 分鐘) + +#### 6.1 功能測試清單 + +| 測試項目 | 預期結果 | 狀態 | +|---------|---------|------| +| 創建新 Session | Session 出現在列表中 | ⏳ 待測 | +| 切換 Session | 對話記錄正確載入 | ⏳ 待測 | +| 發送訊息 | 訊息出現在對話中 | ⏳ 待測 | +| 更新 Session 標題 | 標題即時更新 | ⏳ 待測 | +| 刪除 Session | Session 從列表移除 | ⏳ 待測 | +| 自動清理 | 24 小時後清理未命名 Session | ⏳ 待測 | +| 用戶隔離 | 不同用戶看不到對方數據 | ⏳ 待測 | + +#### 6.2 性能測試 + +| 測試項目 | 目標 | 狀態 | +|---------|------|------| +| 載入 30 個 Sessions | < 500ms | ⏳ 待測 | +| 載入 100 條訊息 | < 1s | ⏳ 待測 | +| 創建新 Session | < 100ms | ⏳ 待測 | +| 發送訊息 | < 200ms | ⏳ 待測 | + +--- + +## 📝 移交清單 + +### 給 Studio 團隊 + +**必須完成**: +- [ ] 創建所有新文件(API、composable、components) +- [ ] 修改 SearchView.vue +- [ ] 移除舊的 search_history 代碼 +- [ ] 更新環境變數(VITE_MARKBASE_URL) +- [ ] 測試所有功能 + +**可選優化**: +- [ ] 添加離線緩存 +- [ ] 添加搜索結果高亮 +- [ ] 添加拖拽排序 Sessions +- [ ] 添加 Session 分享功能 + +--- + +## 🔗 相關資源 + +**API 文檔**: +- `/Users/accusys/markbase/docs/SEARCH_SESSION_API_REPORT.md` +- `/Users/accusys/momentry_studio/docs/MARKBASE_SEARCH_SESSION_API.md` + +**API 端點**: +- `http://localhost:11438/api/v2/search-sessions` +- Token: 從 `/api/v2/admin/login` 獲取 + +**測試帳號**: +- Username: `demo` +- Password: `demo123` + +--- + +## ⚠️ 注意事項 + +1. **Token 管理**: + - Token 存儲在 localStorage + - Token 過期時需要重新登錄 + +2. **錯誤處理**: + - API 返回 `{ success: false, error: "..." }` 格式 + - 需要統一處理錯誤提示 + +3. **性能考慮**: + - Sessions 列表默認 30 條 + - 需要實現滾動載入(無限滾動) + +4. **用戶隔離**: + - API 自動根據 Token 過濾 user_id + - 前端不需要手動傳遞 user_id + +--- + +## 🎯 成功標準 + +**功能完整性**: +- ✅ 所有 7 個 API 端點可用 +- ✅ Session CRUD 功能正常 +- ✅ 訊息發送/接收功能正常 + +**性能達標**: +- ✅ API 響應時間 < 100ms +- ✅ UI 流暢度 > 60fps + +**用戶體驗**: +- ✅ 直觀的 UI 操作 +- ✅ 即時反饋 +- ✅ 錯誤提示友好 + +--- + +**整合計劃完成,Studio 團隊可開始實施。** + +**預計完成時間**:2-3 小時 +**聯繫方式**:如有問題請參考 API 文檔或聯繫 MarkBase 團隊 \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e01ad08..7df138d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1593,6 +1593,15 @@ dependencies = [ "dirs-sys 0.5.0", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -1990,12 +1999,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2450,7 +2453,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -3766,7 +3769,7 @@ dependencies = [ "ctr 0.9.2", "dashmap", "dav-server", - "dirs 5.0.1", + "dirs 7.0.0", "ed25519-dalek 2.2.0", "env_logger", "filetime", @@ -3783,7 +3786,6 @@ dependencies = [ "nix 0.29.0", "once_cell", "poly1305 0.8.0", - "postgres", "pulldown-cmark", "rand 0.8.6", "rayon", @@ -3804,7 +3806,6 @@ dependencies = [ "tar", "tempfile", "tokio", - "tokio-postgres", "tokio-util", "toml 0.8.2", "tower-http 0.5.2", @@ -3935,7 +3936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -4337,6 +4338,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -4348,6 +4359,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -4360,15 +4382,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5027,49 +5040,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres" -version = "0.19.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "postgres-protocol", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -5653,7 +5623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ "bitflags 2.13.0", - "fallible-iterator 0.3.0", + "fallible-iterator", "fallible-streaming-iterator", "hashlink 0.9.1", "libsqlite3-sys", @@ -6795,7 +6765,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -6832,7 +6802,7 @@ dependencies = [ "stringprep", "thiserror 2.0.18", "tracing", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -7094,16 +7064,17 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.32.1" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ - "core-foundation-sys", "libc", "memchr", "ntapi", - "rayon", - "windows 0.57.0", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows 0.62.2", ] [[package]] @@ -7635,32 +7606,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.1", - "socket2 0.6.4", - "tokio", - "tokio-util", - "whoami 2.1.2", -] - [[package]] name = "tokio-stream" version = "0.1.18" @@ -8263,15 +8208,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -8296,15 +8232,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -8547,20 +8474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite 0.1.0", -] - -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite 1.0.2", - "web-sys", + "wasite", ] [[package]] @@ -8609,16 +8523,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.58.0" @@ -8672,18 +8576,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.58.0" @@ -8745,17 +8637,6 @@ dependencies = [ "windows-threading 0.2.1", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-implement" version = "0.58.0" @@ -8778,17 +8659,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-interface" version = "0.58.0" @@ -8843,15 +8713,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.2.0" diff --git a/src/api/index.ts b/src/api/index.ts index ddee773..6bcffe8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -685,6 +685,22 @@ case 'search_keyword': { return { url: '/api/v2/admin/users', method: 'GET', requiresAdminToken: true } case 'list_shares': return { url: '/api/v2/admin/shares', method: 'GET' } + + // --- Search Sessions --- + case 'list_search_sessions': + return { url: '/api/v2/search-sessions', method: 'GET', requiresAdminToken: true } + case 'create_search_session': + return { url: '/api/v2/search-sessions', method: 'POST', body: { title: a.title, mode: a.mode }, requiresAdminToken: true } + case 'rename_search_session': + return { url: `/api/v2/search-sessions/${a.id}`, method: 'PUT', body: { title: a.title }, requiresAdminToken: true } + case 'delete_search_session': + return { url: `/api/v2/search-sessions/${a.id}`, method: 'DELETE', requiresAdminToken: true } + case 'get_session_messages': + return { url: `/api/v2/search-sessions/${a.sessionId}/messages`, method: 'GET', requiresAdminToken: true } + case 'save_session_message': + return { url: `/api/v2/search-sessions/${a.sessionId}/messages`, method: 'POST', body: a.message, requiresAdminToken: true } + case 'update_session_message': + return { url: `/api/v2/search-sessions/${a.sessionId}/messages/${a.msgId}`, method: 'PATCH', body: a.message, requiresAdminToken: true } case 'admin_login': return { url: '/api/v2/admin/login', method: 'POST', body: { username: a.username, password: a.password } } diff --git a/src/composables/useSearchSession.ts b/src/composables/useSearchSession.ts new file mode 100644 index 0000000..d8c5efb --- /dev/null +++ b/src/composables/useSearchSession.ts @@ -0,0 +1,219 @@ +import { ref } from 'vue' +import { getApiBase } from '@/api/config' + +export interface SearchSession { + id: string + title: string + mode: 'keyword' | 'visual' | 'people' | 'agent' + message_count: number + created_at: string + updated_at: string +} + +export interface SearchMessage { + id: string + session_id: string + role: 'user' | 'assistant' + content: string + results: string + mode: string + created_at: string +} + +export function useSearchSession() { + const sessions = ref([]) + const currentSession = ref(null) + const messages = ref([]) + const loading = ref(false) + const error = ref(null) + + function getAuthHeaders(): Record { + const stored = localStorage.getItem('mb_admin_token') + if (stored) { + try { + const data = JSON.parse(stored) + if (new Date(data.expires_at) > new Date()) { + return { 'Authorization': `Bearer ${data.token}` } + } + } catch {} + } + return {} + } + + async function loadSessions() { + loading.value = true + error.value = null + try { + const base = getApiBase() + const resp = await fetch(`${base}/api/v2/search-sessions`, { + headers: getAuthHeaders() + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() + sessions.value = data.sessions || [] + } catch (e: any) { + error.value = e.message || 'Failed to load sessions' + } finally { + loading.value = false + } + } + + async function createSession(title?: string, mode?: string) { + loading.value = true + error.value = null + try { + const base = getApiBase() + const resp = await fetch(`${base}/api/v2/search-sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ title: title || '未命名搜尋', mode: mode || 'keyword' }) + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() + await loadSessions() + return data.session + } catch (e: any) { + error.value = e.message || 'Failed to create session' + return null + } finally { + loading.value = false + } + } + + async function renameSession(id: string, title: string) { + loading.value = true + error.value = null + try { + const base = getApiBase() + const resp = await fetch(`${base}/api/v2/search-sessions/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ title }) + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + await loadSessions() + if (currentSession.value?.id === id) { + currentSession.value.title = title + } + } catch (e: any) { + error.value = e.message || 'Failed to rename session' + } finally { + loading.value = false + } + } + + async function deleteSession(id: string) { + loading.value = true + error.value = null + try { + const base = getApiBase() + const resp = await fetch(`${base}/api/v2/search-sessions/${id}`, { + method: 'DELETE', + headers: getAuthHeaders() + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + if (currentSession.value?.id === id) { + currentSession.value = null + messages.value = [] + } + await loadSessions() + } catch (e: any) { + error.value = e.message || 'Failed to delete session' + } finally { + loading.value = false + } + } + + async function loadMessages(sessionId: string) { + loading.value = true + error.value = null + try { + const base = getApiBase() + const resp = await fetch(`${base}/api/v2/search-sessions/${sessionId}/messages`, { + headers: getAuthHeaders() + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() + messages.value = data.messages || [] + } catch (e: any) { + error.value = e.message || 'Failed to load messages' + } finally { + loading.value = false + } + } + + async function saveMessage(sessionId: string, message: { role: string; content: string; mode?: string; results?: any }) { + loading.value = true + error.value = null + try { + const base = getApiBase() + const resp = await fetch(`${base}/api/v2/search-sessions/${sessionId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ + role: message.role, + content: message.content, + mode: message.mode || 'keyword', + results: message.results ? JSON.stringify(message.results) : '' + }) + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() + await loadMessages(sessionId) + await loadSessions() + return data.message + } catch (e: any) { + error.value = e.message || 'Failed to save message' + return null + } finally { + loading.value = false + } + } + + async function updateMessage(sessionId: string, msgId: string, update: { role?: string; content?: string; results?: any }) { + loading.value = true + error.value = null + try { + const base = getApiBase() + const resp = await fetch(`${base}/api/v2/search-sessions/${sessionId}/messages/${msgId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ + ...update, + results: update.results ? JSON.stringify(update.results) : undefined + }) + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + await loadMessages(sessionId) + } catch (e: any) { + error.value = e.message || 'Failed to update message' + } finally { + loading.value = false + } + } + + function setCurrentSession(session: SearchSession | null) { + currentSession.value = session + if (session) { + loadMessages(session.id) + } else { + messages.value = [] + } + } + + return { + sessions, + currentSession, + messages, + loading, + error, + loadSessions, + createSession, + renameSession, + deleteSession, + loadMessages, + saveMessage, + updateMessage, + setCurrentSession + } +} diff --git a/src/views/SearchView.vue b/src/views/SearchView.vue index 272501f..72ea4d6 100644 --- a/src/views/SearchView.vue +++ b/src/views/SearchView.vue @@ -34,31 +34,27 @@ +
-
No history yet
-
+
No sessions yet
+
- {{ formatMode(h.mode) }} -
{{ h.title || h.query }}
-
-
- - + {{ formatMode(s.mode) }} +
{{ s.title }}
+
{{ s.message_count }} messages · {{ formatSessionTime(s.updated_at) }}
- -
- - - - + +
+ +
-
+
Rename conversation
@@ -127,7 +123,7 @@ {{ r.file_uuid }}
- +
@@ -296,9 +292,10 @@ import { apiCall } from '@/api' import { getApiBase } from '@/api/config' import VideoPlayer from '../components/VideoPlayer.vue' import { useRouter } from 'vue-router' -import { useSearchHistory, useBookmarks } from '@/composables/useSearchHistory' +import { useSearchSession } from '@/composables/useSearchSession' +import { useBookmarks } from '@/composables/useSearchHistory' import { thumbnailsCache, loadThumbnail, filesCache, ensureFiles } from '@/store' -import type { HistoryItem } from '@/composables/useSearchHistory' +import type { SearchSession, SearchMessage } from '@/composables/useSearchSession' interface SourceInfo { tool: string @@ -342,12 +339,12 @@ const showModeMenu = ref(false) const showBookmark = ref(false) const showHistory = ref(false) const isListening = ref(false) -const activeHistoryMenu = ref(null) -const renamingHistory = ref(null) +const activeSessionMenu = ref(null) +const renamingSession = ref(null) const renameValue = ref('') const renameInputEl = ref(null) -const { history: historyList, loadHistory, saveToHistory, renameHistory, pinHistory, deleteHistory, restoreFromHistory } = useSearchHistory() +const { sessions: sessionList, currentSession, messages: sessionMessages, loading: sessionLoading, error: sessionError, loadSessions, createSession, renameSession, deleteSession, loadMessages, saveMessage, setCurrentSession } = useSearchSession() const { bookmarks: bookmarkList, loadBookmarks, saveBookmark, deleteBookmark } = useBookmarks() const markedResults = ref([]) @@ -578,7 +575,7 @@ function toggleBookmark() { function toggleHistory() { showHistory.value = !showHistory.value showBookmark.value = false - if (showHistory.value) loadHistory() + if (showHistory.value) loadSessions() } function toggleVoice() { @@ -593,7 +590,7 @@ function closeDropdowns(e: MouseEvent) { showHistory.value = false } if (!target.closest('.hs-menu-btn') && !target.closest('.hs-menu')) { - activeHistoryMenu.value = null + activeSessionMenu.value = null } if (!target.closest('.sel-popup-btn')) { selectionPopup.value.show = false @@ -603,9 +600,20 @@ function closeDropdowns(e: MouseEvent) { onMounted(async () => { document.addEventListener('click', closeDropdowns) await ensureFiles() - loadHistory() + await loadSessions() loadBookmarks() + // Auto-create default session if none exists + if (!sessionList.value.length) { + await createSession('Default', 'keyword') + await loadSessions() + } + + // Set first session as current + if (sessionList.value.length && !currentSession.value) { + setCurrentSession(sessionList.value[0]) + } + // Restore session from localStorage try { const saved = localStorage.getItem(SESSION_STORAGE_KEY) @@ -703,59 +711,61 @@ function startNewChat() { showHistory.value = false } -function toggleHistoryMenu(id: string) { - activeHistoryMenu.value = activeHistoryMenu.value === id ? null : id +function toggleSessionMenu(id: string) { + activeSessionMenu.value = activeSessionMenu.value === id ? null : id } -async function pinHistoryItem(id: string) { - await pinHistory(id, true) - activeHistoryMenu.value = null +async function handleDeleteSession(id: string) { + await deleteSession(id) + activeSessionMenu.value = null } -async function unpinHistoryItem(id: string) { - await pinHistory(id, false) - activeHistoryMenu.value = null -} - -async function deleteHistoryItem(id: string) { - await deleteHistory(id) - activeHistoryMenu.value = null -} - -function startRenameHistory(h: HistoryItem) { - renamingHistory.value = h - renameValue.value = h.title || h.query - activeHistoryMenu.value = null +function startRenameSession(s: SearchSession) { + renamingSession.value = s + renameValue.value = s.title + activeSessionMenu.value = null nextTick(() => renameInputEl.value?.focus()) } async function confirmRename() { - if (!renamingHistory.value) return - await renameHistory(renamingHistory.value.id, renameValue.value) - renamingHistory.value = null + if (!renamingSession.value) return + await renameSession(renamingSession.value.id, renameValue.value) + renamingSession.value = null renameValue.value = '' } function cancelRename() { - renamingHistory.value = null + renamingSession.value = null renameValue.value = '' } -function restoreHistory(h: HistoryItem) { - const restored = restoreFromHistory(h) - if (h.mode && modes.some(m => m.value === h.mode)) { - mode.value = h.mode! - localStorage.setItem('ms_chat_mode_v1', h.mode!) +async function handleSelectSession(s: SearchSession) { + setCurrentSession(s) + showHistory.value = false + // Restore messages from session + const restoredMessages = sessionMessages.value.map((m: SearchMessage) => { + const isUser = m.role === 'user' + return { + role: m.role, + query: isUser ? m.content : undefined, + results: m.results ? JSON.parse(m.results) : [], + loading: false, + message: isUser ? '' : m.content, + mode: m.mode + } + }) + if (restoredMessages.length) { + messages.value = restoredMessages } - if (restored) { - messages.value = restored - query.value = '' + nextTick(() => scrollToBottom()) +} + +async function handleNewSession() { + const newSession = await createSession('未命名搜尋', mode.value) + if (newSession) { + setCurrentSession(newSession) + messages.value = [] showHistory.value = false - nextTick(() => scrollToBottom()) - } else { - query.value = h.query - showHistory.value = false - sendMessage() } } @@ -1231,7 +1241,25 @@ async function sendMessage() { if (searchSeq === seq) { scrollToBottom() try { - await saveToHistory(q, JSON.stringify(messages.value), mode.value) + // Save to session if there's a current session + if (currentSession.value) { + // Save user message + await saveMessage(currentSession.value.id, { + role: 'user', + content: q, + mode: mode.value + }) + // Save assistant message with results + const lastMsg = messages.value[messages.value.length - 1] + if (lastMsg && lastMsg.role === 'assistant') { + await saveMessage(currentSession.value.id, { + role: 'assistant', + content: lastMsg.message || lastMsg.answer || '', + mode: mode.value, + results: lastMsg.results + }) + } + } } catch {} nextTick(() => { queryInputEl.value?.focus() @@ -1464,6 +1492,20 @@ function formatMode(mode: string | null): string { return map[mode || ''] || '—' } +function formatSessionTime(ts: string | null): string { + if (!ts) return '' + const norm = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts) ? `${ts}Z` : ts + const d = new Date(norm) + if (isNaN(d.getTime())) return ts + const now = new Date() + const diff = now.getTime() - d.getTime() + if (diff < 60000) return 'Just now' + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago` + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago` + const pad = (n: number) => n.toString().padStart(2, '0') + return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} + function formatHistoryTime(ts: string | null): string { if (!ts) return '' // SQLite datetime('now') writes "YYYY-MM-DD HH:MM:SS" (no TZ) while Rust writes @@ -1840,6 +1882,24 @@ function openSource(src: SourceInfo) { flex-shrink: 0; } .hs-item:hover .hs-menu-btn { visibility: visible; } +.hs-item.active { background: var(--primary-color); color: white; } +.hs-item.active .hs-mode { background: rgba(255, 255, 255, 0.2); color: white; } +.hs-item.active .hs-time { color: rgba(255, 255, 255, 0.8); } +.hs-item.active .hs-title { color: white; font-weight: 600; } + +.hs-new-btn { + width: 100%; + padding: 8px; + margin: 4px 0; + background: var(--primary-color); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; +} +.hs-new-btn:hover { opacity: 0.9; } .hs-menu-btn:hover { background: var(--border-light); } .hs-menu { position: absolute;