From fe91afff988a3b93dd2401a179fe7e770612bb6b Mon Sep 17 00:00:00 2001 From: Momentry Studio Date: Tue, 27 Oct 2026 05:06:03 +0800 Subject: [PATCH] feat: integrate MarkBase Search Session API into Studio - Created useSearchSession composable for session management - Added 7 API endpoints in api/index.ts - Updated SearchView.vue to use sessions instead of history - Session sidebar with create, rename, delete functionality - Auto-save messages to current session after each search - Auto-create default session on first load - Updated CSS for session active state and new session button --- data/users/demo.sqlite-shm | Bin 32768 -> 32768 bytes docs/MARKBASE_SEARCH_SESSION_API.md | 344 ++++++++ docs/STUDIO_INTEGRATION_PLAN.md | 1216 +++++++++++++++++++++++++++ src-tauri/Cargo.lock | 225 +---- src/api/index.ts | 16 + src/composables/useSearchSession.ts | 219 +++++ src/views/SearchView.vue | 182 ++-- 7 files changed, 1959 insertions(+), 243 deletions(-) create mode 100644 docs/MARKBASE_SEARCH_SESSION_API.md create mode 100644 docs/STUDIO_INTEGRATION_PLAN.md create mode 100644 src/composables/useSearchSession.ts diff --git a/data/users/demo.sqlite-shm b/data/users/demo.sqlite-shm index a95cd29855499fa2b350585ff88ad1624eee713f..3b9724d6a5c155f201ad7349f10abc12d3d99580 100644 GIT binary patch delta 491 zcmb7<+bcv-6vofD=bRI{G>mI0c_E1xPafThnkPv^8dBt*iQH%A9LBgaW4q0`-^Z_!BTho}PF?Y&Ku4IHvf+U$wDQz)HQf^&yHTU-G zyFf?Nd~opTA?M{GL-}m@?|gVUC1dZ`pZMWSn!eQE75}4INg{OYze(m7ip-W`~CUn(WfGe)6*-EuEA^G`R<&dcb zYgC;)RG0_dAiSdJHOdJOX0V72>=Vo9!^L@6;11h|I4rrAQ(YLv7$z`@Y0QFS3G3Ly z0ZwT+QS6qVu^2bJn8F;Ev57;%rDEG^kHd;^*s{QVYNuGN+n1v>jAI_F*uv3oCsX(u zYhemtn@BoDZ1Xd)Lo513L+cd7F4=BT-{EBlg{i&E!*EP?K-6}VJqg>IUK=oCycq%d VbB=iGzm?&mo)baJZgJwO!Z%6Gt*8J1 delta 324 zcmZo@U}|V!s+V}A%K!t63=9G_Kw>@<14BeXWNne}q4$?(=BD+}s=8Hn@7J}uGa*H! zs)v~kGWS0cfQmCQ)NE}0&brxz-OF-v0Q(-svy&a!HJKwBI)HR7W9H { + 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;