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
This commit is contained in:
Binary file not shown.
344
docs/MARKBASE_SEARCH_SESSION_API.md
Normal file
344
docs/MARKBASE_SEARCH_SESSION_API.md
Normal file
@@ -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 小時** |
|
||||||
1216
docs/STUDIO_INTEGRATION_PLAN.md
Normal file
1216
docs/STUDIO_INTEGRATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
225
src-tauri/Cargo.lock
generated
225
src-tauri/Cargo.lock
generated
@@ -1593,6 +1593,15 @@ dependencies = [
|
|||||||
"dirs-sys 0.5.0",
|
"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]]
|
[[package]]
|
||||||
name = "dirs-sys"
|
name = "dirs-sys"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1990,12 +1999,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fallible-iterator"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fallible-iterator"
|
name = "fallible-iterator"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -2450,7 +2453,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3766,7 +3769,7 @@ dependencies = [
|
|||||||
"ctr 0.9.2",
|
"ctr 0.9.2",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dav-server",
|
"dav-server",
|
||||||
"dirs 5.0.1",
|
"dirs 7.0.0",
|
||||||
"ed25519-dalek 2.2.0",
|
"ed25519-dalek 2.2.0",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"filetime",
|
"filetime",
|
||||||
@@ -3783,7 +3786,6 @@ dependencies = [
|
|||||||
"nix 0.29.0",
|
"nix 0.29.0",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"poly1305 0.8.0",
|
"poly1305 0.8.0",
|
||||||
"postgres",
|
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -3804,7 +3806,6 @@ dependencies = [
|
|||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-postgres",
|
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
@@ -3935,7 +3936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4337,6 +4338,16 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -4348,6 +4359,17 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -4360,15 +4382,6 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-ui-kit"
|
name = "objc2-ui-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -5027,49 +5040,6 @@ dependencies = [
|
|||||||
"portable-atomic",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -5653,7 +5623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"fallible-iterator 0.3.0",
|
"fallible-iterator",
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink 0.9.1",
|
"hashlink 0.9.1",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
@@ -6795,7 +6765,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami 1.6.1",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6832,7 +6802,7 @@ dependencies = [
|
|||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami 1.6.1",
|
"whoami",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7094,16 +7064,17 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysinfo"
|
name = "sysinfo"
|
||||||
version = "0.32.1"
|
version = "0.39.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af"
|
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"ntapi",
|
"ntapi",
|
||||||
"rayon",
|
"objc2-core-foundation",
|
||||||
"windows 0.57.0",
|
"objc2-io-kit",
|
||||||
|
"objc2-open-directory",
|
||||||
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7635,32 +7606,6 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
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]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.4+wasi-0.2.12"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
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]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.125"
|
version = "0.2.125"
|
||||||
@@ -8547,20 +8474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libredox",
|
"libredox",
|
||||||
"wasite 0.1.0",
|
"wasite",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8609,16 +8523,6 @@ dependencies = [
|
|||||||
"windows-version",
|
"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]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.58.0"
|
version = "0.58.0"
|
||||||
@@ -8672,18 +8576,6 @@ dependencies = [
|
|||||||
"windows-core 0.62.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.58.0"
|
version = "0.58.0"
|
||||||
@@ -8745,17 +8637,6 @@ dependencies = [
|
|||||||
"windows-threading 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.58.0"
|
version = "0.58.0"
|
||||||
@@ -8778,17 +8659,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.58.0"
|
version = "0.58.0"
|
||||||
@@ -8843,15 +8713,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -685,6 +685,22 @@ case 'search_keyword': {
|
|||||||
return { url: '/api/v2/admin/users', method: 'GET', requiresAdminToken: true }
|
return { url: '/api/v2/admin/users', method: 'GET', requiresAdminToken: true }
|
||||||
case 'list_shares':
|
case 'list_shares':
|
||||||
return { url: '/api/v2/admin/shares', method: 'GET' }
|
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':
|
case 'admin_login':
|
||||||
return { url: '/api/v2/admin/login', method: 'POST', body: { username: a.username, password: a.password } }
|
return { url: '/api/v2/admin/login', method: 'POST', body: { username: a.username, password: a.password } }
|
||||||
|
|
||||||
|
|||||||
219
src/composables/useSearchSession.ts
Normal file
219
src/composables/useSearchSession.ts
Normal file
@@ -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<SearchSession[]>([])
|
||||||
|
const currentSession = ref<SearchSession | null>(null)
|
||||||
|
const messages = ref<SearchMessage[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,31 +34,27 @@
|
|||||||
<button class="bkh-btn" title="New Chat" @click="startNewChat">+ New</button>
|
<button class="bkh-btn" title="New Chat" @click="startNewChat">+ New</button>
|
||||||
<button class="bkx" @click="showHistory = false">x</button>
|
<button class="bkx" @click="showHistory = false">x</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="hs-new-btn" @click="handleNewSession">+ New Session</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="msHSL">
|
<div id="msHSL">
|
||||||
<div v-if="!historyList.length" style="color:var(--muted-text);font-size:12px;padding:8px 0">No history yet</div>
|
<div v-if="!sessionList.length" style="color:var(--muted-text);font-size:12px;padding:8px 0">No sessions yet</div>
|
||||||
<div v-for="h in historyList" :key="h.id" class="hs-item" :class="{ pinned: h.pinned }">
|
<div v-for="s in sessionList" :key="s.id" class="hs-item" :class="{ active: currentSession?.id === s.id }">
|
||||||
<div class="hs-main">
|
<div class="hs-main">
|
||||||
<div class="hs-row">
|
<div class="hs-row">
|
||||||
<span class="hs-mode">{{ formatMode(h.mode) }}</span>
|
<span class="hs-mode">{{ formatMode(s.mode) }}</span>
|
||||||
<div class="hs-title" @click="restoreHistory(h)">{{ h.title || h.query }}</div>
|
<div class="hs-title" @click="handleSelectSession(s)">{{ s.title }}</div>
|
||||||
</div>
|
|
||||||
<div class="hs-time">
|
|
||||||
<template v-if="h.updated_at && h.updated_at !== h.created_at">{{ formatHistoryTime(h.created_at) }} - {{ formatHistoryTime(h.updated_at) }}</template>
|
|
||||||
<template v-else>{{ formatHistoryTime(h.created_at || h.updated_at) }}</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hs-time">{{ s.message_count }} messages · {{ formatSessionTime(s.updated_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="hs-menu-btn" @click.stop="toggleHistoryMenu(h.id)">⋯</button>
|
<button class="hs-menu-btn" @click.stop="toggleSessionMenu(s.id)">⋯</button>
|
||||||
<div v-if="activeHistoryMenu === h.id" class="hs-menu">
|
<div v-if="activeSessionMenu === s.id" class="hs-menu">
|
||||||
<button v-if="!h.pinned" @click="pinHistoryItem(h.id)">Pin</button>
|
<button @click="startRenameSession(s)">Rename</button>
|
||||||
<button v-else @click="unpinHistoryItem(h.id)">Unpin</button>
|
<button class="hs-menu-del" @click="handleDeleteSession(s.id)">Delete</button>
|
||||||
<button @click="startRenameHistory(h)">Rename</button>
|
|
||||||
<button class="hs-menu-del" @click="deleteHistoryItem(h.id)">Delete</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="renamingHistory" class="rename-overlay" @click.self="cancelRename">
|
<div v-if="renamingSession" class="rename-overlay" @click.self="cancelRename">
|
||||||
<div class="rename-dialog">
|
<div class="rename-dialog">
|
||||||
<div class="rename-label">Rename conversation</div>
|
<div class="rename-label">Rename conversation</div>
|
||||||
<input class="rename-input" v-model="renameValue" @keyup.enter="confirmRename" ref="renameInputEl" />
|
<input class="rename-input" v-model="renameValue" @keyup.enter="confirmRename" ref="renameInputEl" />
|
||||||
@@ -127,7 +123,7 @@
|
|||||||
<span class="card-file-uuid-badge">{{ r.file_uuid }}</span>
|
<span class="card-file-uuid-badge">{{ r.file_uuid }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-thumb" @click="playVideo(r)">
|
<div class="card-thumb" @click="playVideo(r)">
|
||||||
<img v-if="r.thumbnail_url" :src="r.thumbnail_url" alt="" loading="lazy" @error="$event.target.style.display='none'">
|
<img v-if="r.thumbnail_url" :src="r.thumbnail_url" alt="" loading="lazy" @error="($event.target as HTMLElement).style.display='none'">
|
||||||
<img v-else-if="thumbs[r.file_uuid + ':' + r.start_frame]" :src="thumbs[r.file_uuid + ':' + r.start_frame]" alt="" loading="lazy">
|
<img v-else-if="thumbs[r.file_uuid + ':' + r.start_frame]" :src="thumbs[r.file_uuid + ':' + r.start_frame]" alt="" loading="lazy">
|
||||||
<div v-else class="thumb-placeholder" v-observe="() => loadThumb(r.file_uuid, r.start_frame || 0)">
|
<div v-else class="thumb-placeholder" v-observe="() => loadThumb(r.file_uuid, r.start_frame || 0)">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
@@ -296,9 +292,10 @@ import { apiCall } from '@/api'
|
|||||||
import { getApiBase } from '@/api/config'
|
import { getApiBase } from '@/api/config'
|
||||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 { thumbnailsCache, loadThumbnail, filesCache, ensureFiles } from '@/store'
|
||||||
import type { HistoryItem } from '@/composables/useSearchHistory'
|
import type { SearchSession, SearchMessage } from '@/composables/useSearchSession'
|
||||||
|
|
||||||
interface SourceInfo {
|
interface SourceInfo {
|
||||||
tool: string
|
tool: string
|
||||||
@@ -342,12 +339,12 @@ const showModeMenu = ref(false)
|
|||||||
const showBookmark = ref(false)
|
const showBookmark = ref(false)
|
||||||
const showHistory = ref(false)
|
const showHistory = ref(false)
|
||||||
const isListening = ref(false)
|
const isListening = ref(false)
|
||||||
const activeHistoryMenu = ref<string | null>(null)
|
const activeSessionMenu = ref<string | null>(null)
|
||||||
const renamingHistory = ref<HistoryItem | null>(null)
|
const renamingSession = ref<SearchSession | null>(null)
|
||||||
const renameValue = ref('')
|
const renameValue = ref('')
|
||||||
const renameInputEl = ref<HTMLInputElement | null>(null)
|
const renameInputEl = ref<HTMLInputElement | null>(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 { bookmarks: bookmarkList, loadBookmarks, saveBookmark, deleteBookmark } = useBookmarks()
|
||||||
|
|
||||||
const markedResults = ref<any[]>([])
|
const markedResults = ref<any[]>([])
|
||||||
@@ -578,7 +575,7 @@ function toggleBookmark() {
|
|||||||
function toggleHistory() {
|
function toggleHistory() {
|
||||||
showHistory.value = !showHistory.value
|
showHistory.value = !showHistory.value
|
||||||
showBookmark.value = false
|
showBookmark.value = false
|
||||||
if (showHistory.value) loadHistory()
|
if (showHistory.value) loadSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVoice() {
|
function toggleVoice() {
|
||||||
@@ -593,7 +590,7 @@ function closeDropdowns(e: MouseEvent) {
|
|||||||
showHistory.value = false
|
showHistory.value = false
|
||||||
}
|
}
|
||||||
if (!target.closest('.hs-menu-btn') && !target.closest('.hs-menu')) {
|
if (!target.closest('.hs-menu-btn') && !target.closest('.hs-menu')) {
|
||||||
activeHistoryMenu.value = null
|
activeSessionMenu.value = null
|
||||||
}
|
}
|
||||||
if (!target.closest('.sel-popup-btn')) {
|
if (!target.closest('.sel-popup-btn')) {
|
||||||
selectionPopup.value.show = false
|
selectionPopup.value.show = false
|
||||||
@@ -603,9 +600,20 @@ function closeDropdowns(e: MouseEvent) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('click', closeDropdowns)
|
document.addEventListener('click', closeDropdowns)
|
||||||
await ensureFiles()
|
await ensureFiles()
|
||||||
loadHistory()
|
await loadSessions()
|
||||||
loadBookmarks()
|
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
|
// Restore session from localStorage
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(SESSION_STORAGE_KEY)
|
const saved = localStorage.getItem(SESSION_STORAGE_KEY)
|
||||||
@@ -703,59 +711,61 @@ function startNewChat() {
|
|||||||
showHistory.value = false
|
showHistory.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHistoryMenu(id: string) {
|
function toggleSessionMenu(id: string) {
|
||||||
activeHistoryMenu.value = activeHistoryMenu.value === id ? null : id
|
activeSessionMenu.value = activeSessionMenu.value === id ? null : id
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pinHistoryItem(id: string) {
|
async function handleDeleteSession(id: string) {
|
||||||
await pinHistory(id, true)
|
await deleteSession(id)
|
||||||
activeHistoryMenu.value = null
|
activeSessionMenu.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unpinHistoryItem(id: string) {
|
function startRenameSession(s: SearchSession) {
|
||||||
await pinHistory(id, false)
|
renamingSession.value = s
|
||||||
activeHistoryMenu.value = null
|
renameValue.value = s.title
|
||||||
}
|
activeSessionMenu.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
|
|
||||||
nextTick(() => renameInputEl.value?.focus())
|
nextTick(() => renameInputEl.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRename() {
|
async function confirmRename() {
|
||||||
if (!renamingHistory.value) return
|
if (!renamingSession.value) return
|
||||||
await renameHistory(renamingHistory.value.id, renameValue.value)
|
await renameSession(renamingSession.value.id, renameValue.value)
|
||||||
renamingHistory.value = null
|
renamingSession.value = null
|
||||||
renameValue.value = ''
|
renameValue.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelRename() {
|
function cancelRename() {
|
||||||
renamingHistory.value = null
|
renamingSession.value = null
|
||||||
renameValue.value = ''
|
renameValue.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreHistory(h: HistoryItem) {
|
async function handleSelectSession(s: SearchSession) {
|
||||||
const restored = restoreFromHistory(h)
|
setCurrentSession(s)
|
||||||
if (h.mode && modes.some(m => m.value === h.mode)) {
|
showHistory.value = false
|
||||||
mode.value = h.mode!
|
// Restore messages from session
|
||||||
localStorage.setItem('ms_chat_mode_v1', h.mode!)
|
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) {
|
nextTick(() => scrollToBottom())
|
||||||
messages.value = restored
|
}
|
||||||
query.value = ''
|
|
||||||
|
async function handleNewSession() {
|
||||||
|
const newSession = await createSession('未命名搜尋', mode.value)
|
||||||
|
if (newSession) {
|
||||||
|
setCurrentSession(newSession)
|
||||||
|
messages.value = []
|
||||||
showHistory.value = false
|
showHistory.value = false
|
||||||
nextTick(() => scrollToBottom())
|
|
||||||
} else {
|
|
||||||
query.value = h.query
|
|
||||||
showHistory.value = false
|
|
||||||
sendMessage()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,7 +1241,25 @@ async function sendMessage() {
|
|||||||
if (searchSeq === seq) {
|
if (searchSeq === seq) {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
try {
|
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 {}
|
} catch {}
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
queryInputEl.value?.focus()
|
queryInputEl.value?.focus()
|
||||||
@@ -1464,6 +1492,20 @@ function formatMode(mode: string | null): string {
|
|||||||
return map[mode || ''] || '—'
|
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 {
|
function formatHistoryTime(ts: string | null): string {
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
// SQLite datetime('now') writes "YYYY-MM-DD HH:MM:SS" (no TZ) while Rust writes
|
// 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;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.hs-item:hover .hs-menu-btn { visibility: visible; }
|
.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-btn:hover { background: var(--border-light); }
|
||||||
.hs-menu {
|
.hs-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in New Issue
Block a user