- 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
1216 lines
26 KiB
Markdown
1216 lines
26 KiB
Markdown
# 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<T> {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: string;
|
||
code?: string;
|
||
}
|
||
|
||
export class SearchSessionApi {
|
||
private token: string;
|
||
|
||
constructor(token: string) {
|
||
this.token = token;
|
||
}
|
||
|
||
private async request<T>(
|
||
endpoint: string,
|
||
options: RequestInit = {}
|
||
): Promise<ApiResponse<T>> {
|
||
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<ApiResponse<{ sessions: SearchSession[]; total: number }>> {
|
||
return this.request(`/api/v2/search-sessions?limit=${limit}&offset=${offset}`);
|
||
}
|
||
|
||
async createSession(title: string, mode: string): Promise<ApiResponse<{ session: SearchSession }>> {
|
||
return this.request('/api/v2/search-sessions', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ title, mode }),
|
||
});
|
||
}
|
||
|
||
async updateSession(id: string, title: string): Promise<ApiResponse<void>> {
|
||
return this.request(`/api/v2/search-sessions/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ title }),
|
||
});
|
||
}
|
||
|
||
async deleteSession(id: string): Promise<ApiResponse<void>> {
|
||
return this.request(`/api/v2/search-sessions/${id}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
// Messages
|
||
async listMessages(sessionId: string): Promise<ApiResponse<{ messages: SearchMessage[] }>> {
|
||
return this.request(`/api/v2/search-sessions/${sessionId}/messages`);
|
||
}
|
||
|
||
async createMessage(
|
||
sessionId: string,
|
||
role: 'user' | 'assistant',
|
||
content: string,
|
||
mode: string,
|
||
results?: string
|
||
): Promise<ApiResponse<{ message: SearchMessage }>> {
|
||
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<Pick<SearchMessage, 'role' | 'content' | 'results'>>
|
||
): Promise<ApiResponse<void>> {
|
||
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<SearchSession[]>([]);
|
||
const currentSession = ref<SearchSession | null>(null);
|
||
const messages = ref<SearchMessage[]>([]);
|
||
const isLoading = ref(false);
|
||
const error = ref<string | null>(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
|
||
<!-- src/components/search/SessionList.vue -->
|
||
<template>
|
||
<div class="session-list">
|
||
<div class="session-header">
|
||
<h3>搜索歷史</h3>
|
||
<button
|
||
class="new-session-btn"
|
||
@click="$emit('create-new')"
|
||
:disabled="isLoading"
|
||
>
|
||
+ 新搜尋
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sessions" v-if="sessions.length > 0">
|
||
<SessionItem
|
||
v-for="session in sessions"
|
||
:key="session.id"
|
||
:session="session"
|
||
:is-active="currentSession?.id === session.id"
|
||
@click="$emit('select', session.id)"
|
||
@update="handleUpdate(session.id, $event)"
|
||
@delete="handleDelete(session.id)"
|
||
/>
|
||
</div>
|
||
|
||
<div class="empty-state" v-else>
|
||
<p>尚無搜索記錄</p>
|
||
<button @click="$emit('create-new')">開始第一次搜尋</button>
|
||
</div>
|
||
|
||
<div class="loading" v-if="isLoading">
|
||
<span>載入中...</span>
|
||
</div>
|
||
|
||
<div class="error" v-if="error">
|
||
<span>{{ error }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { defineProps, defineEmits } from 'vue';
|
||
import SessionItem from './SessionItem.vue';
|
||
import type { SearchSession } from '@/types/search';
|
||
|
||
defineProps<{
|
||
sessions: SearchSession[];
|
||
currentSession: SearchSession | null;
|
||
isLoading: boolean;
|
||
error: string | null;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'create-new'): void;
|
||
(e: 'select', sessionId: string): void;
|
||
(e: 'update', sessionId: string, title: string): void;
|
||
(e: 'delete', sessionId: string): void;
|
||
}>();
|
||
|
||
function handleUpdate(sessionId: string, title: string) {
|
||
emit('update', sessionId, title);
|
||
}
|
||
|
||
function handleDelete(sessionId: string) {
|
||
if (confirm('確定要刪除此搜尋記錄?')) {
|
||
emit('delete', sessionId);
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.session-list {
|
||
width: 280px;
|
||
height: 100%;
|
||
border-right: 1px solid #e0e0e0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.session-header {
|
||
padding: 16px;
|
||
border-bottom: 1px solid #e0e0e0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.session-header h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.new-session-btn {
|
||
padding: 6px 12px;
|
||
background: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.new-session-btn:hover {
|
||
background: #40a9ff;
|
||
}
|
||
|
||
.sessions {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.empty-state {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
color: #999;
|
||
}
|
||
|
||
.empty-state button {
|
||
margin-top: 12px;
|
||
padding: 8px 16px;
|
||
background: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.loading, .error {
|
||
padding: 12px;
|
||
text-align: center;
|
||
}
|
||
|
||
.error {
|
||
color: #f5222d;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
#### 3.2 創建 SessionItem 組件
|
||
|
||
**文件**:`src/components/search/SessionItem.vue`(新建)
|
||
|
||
```vue
|
||
<!-- src/components/search/SessionItem.vue -->
|
||
<template>
|
||
<div
|
||
class="session-item"
|
||
:class="{ active: isActive }"
|
||
@click="$emit('click')"
|
||
>
|
||
<div class="session-title" v-if="!isEditing">
|
||
{{ session.title }}
|
||
</div>
|
||
<input
|
||
v-else
|
||
v-model="editTitle"
|
||
class="session-title-input"
|
||
@blur="handleSave"
|
||
@keyup.enter="handleSave"
|
||
@keyup.esc="isEditing = false"
|
||
ref="titleInput"
|
||
/>
|
||
|
||
<div class="session-meta">
|
||
<span class="mode-badge">{{ modeText }}</span>
|
||
<span class="message-count">{{ session.message_count }} 則訊息</span>
|
||
</div>
|
||
|
||
<div class="session-actions" v-if="isActive">
|
||
<button class="action-btn" @click.stop="startEdit" title="重新命名">
|
||
✏️
|
||
</button>
|
||
<button class="action-btn" @click.stop="$emit('delete')" title="刪除">
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, nextTick } from 'vue';
|
||
import type { SearchSession } from '@/types/search';
|
||
|
||
const props = defineProps<{
|
||
session: SearchSession;
|
||
isActive: boolean;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'click'): void;
|
||
(e: 'update', title: string): void;
|
||
(e: 'delete'): void;
|
||
}>();
|
||
|
||
const isEditing = ref(false);
|
||
const editTitle = ref('');
|
||
const titleInput = ref<HTMLInputElement | null>(null);
|
||
|
||
const modeText = computed(() => {
|
||
const modeMap = {
|
||
keyword: '關鍵字',
|
||
visual: '視覺搜尋',
|
||
semantic: '語意搜尋',
|
||
};
|
||
return modeMap[props.session.mode] || props.session.mode;
|
||
});
|
||
|
||
function startEdit() {
|
||
editTitle.value = props.session.title;
|
||
isEditing.value = true;
|
||
nextTick(() => {
|
||
titleInput.value?.focus();
|
||
titleInput.value?.select();
|
||
});
|
||
}
|
||
|
||
function handleSave() {
|
||
if (editTitle.value.trim() && editTitle.value !== props.session.title) {
|
||
emit('update', editTitle.value.trim());
|
||
}
|
||
isEditing.value = false;
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.session-item {
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
background: white;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
border: 1px solid #e0e0e0;
|
||
transition: all 0.2s;
|
||
position: relative;
|
||
}
|
||
|
||
.session-item:hover {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.session-item.active {
|
||
border-color: #1890ff;
|
||
background: #e6f7ff;
|
||
}
|
||
|
||
.session-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
color: #333;
|
||
}
|
||
|
||
.session-title-input {
|
||
width: 100%;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
border: 1px solid #1890ff;
|
||
border-radius: 4px;
|
||
padding: 4px 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.session-meta {
|
||
display: flex;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
|
||
.mode-badge {
|
||
padding: 2px 6px;
|
||
background: #f0f0f0;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.session-actions {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.action-btn {
|
||
padding: 4px 8px;
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
opacity: 0.6;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
opacity: 1;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
#### 3.3 創建 ChatArea 組件
|
||
|
||
**文件**:`src/components/search/ChatArea.vue`(新建)
|
||
|
||
```vue
|
||
<!-- src/components/search/ChatArea.vue -->
|
||
<template>
|
||
<div class="chat-area">
|
||
<div class="chat-header" v-if="currentSession">
|
||
<h2>{{ currentSession.title }}</h2>
|
||
<span class="mode-badge">{{ modeText }}</span>
|
||
</div>
|
||
|
||
<div class="messages" ref="messagesContainer">
|
||
<MessageItem
|
||
v-for="message in messages"
|
||
:key="message.id"
|
||
:message="message"
|
||
/>
|
||
|
||
<div class="empty-chat" v-if="messages.length === 0">
|
||
<p>開始新的搜尋對話</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="input-area">
|
||
<select v-model="searchMode" class="mode-selector">
|
||
<option value="keyword">關鍵字搜尋</option>
|
||
<option value="visual">視覺搜尋</option>
|
||
<option value="semantic">語意搜尋</option>
|
||
</select>
|
||
|
||
<input
|
||
v-model="inputText"
|
||
class="chat-input"
|
||
placeholder="輸入搜尋內容..."
|
||
@keyup.enter="handleSend"
|
||
:disabled="isLoading || !currentSession"
|
||
/>
|
||
|
||
<button
|
||
class="send-btn"
|
||
@click="handleSend"
|
||
:disabled="isLoading || !currentSession || !inputText.trim()"
|
||
>
|
||
發送
|
||
</button>
|
||
</div>
|
||
|
||
<div class="loading-overlay" v-if="isLoading">
|
||
<span>搜尋中...</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, nextTick } from 'vue';
|
||
import MessageItem from './MessageItem.vue';
|
||
import type { SearchSession, SearchMessage } from '@/types/search';
|
||
|
||
const props = defineProps<{
|
||
currentSession: SearchSession | null;
|
||
messages: SearchMessage[];
|
||
isLoading: boolean;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'send', content: string, mode: string): void;
|
||
}>();
|
||
|
||
const inputText = ref('');
|
||
const searchMode = ref<'keyword' | 'visual' | 'semantic'>('keyword');
|
||
const messagesContainer = ref<HTMLElement | null>(null);
|
||
|
||
const modeText = computed(() => {
|
||
const modeMap = {
|
||
keyword: '關鍵字',
|
||
visual: '視覺搜尋',
|
||
semantic: '語意搜尋',
|
||
};
|
||
return modeMap[props.currentSession?.mode || 'keyword'] || '關鍵字';
|
||
});
|
||
|
||
function handleSend() {
|
||
if (!inputText.value.trim()) return;
|
||
|
||
emit('send', inputText.value.trim(), searchMode.value);
|
||
inputText.value = '';
|
||
}
|
||
|
||
// Auto-scroll to bottom when new messages arrive
|
||
watch(
|
||
() => props.messages.length,
|
||
() => {
|
||
nextTick(() => {
|
||
if (messagesContainer.value) {
|
||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
);
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: white;
|
||
}
|
||
|
||
.chat-header {
|
||
padding: 16px 24px;
|
||
border-bottom: 1px solid #e0e0e0;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.chat-header h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.mode-badge {
|
||
padding: 4px 8px;
|
||
background: #f0f0f0;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 24px;
|
||
}
|
||
|
||
.empty-chat {
|
||
height: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
color: #999;
|
||
}
|
||
|
||
.input-area {
|
||
padding: 16px 24px;
|
||
border-top: 1px solid #e0e0e0;
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.mode-selector {
|
||
padding: 8px 12px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
background: white;
|
||
}
|
||
|
||
.chat-input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.chat-input:focus {
|
||
outline: none;
|
||
border-color: #1890ff;
|
||
}
|
||
|
||
.send-btn {
|
||
padding: 8px 16px;
|
||
background: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.send-btn:hover:not(:disabled) {
|
||
background: #40a9ff;
|
||
}
|
||
|
||
.send-btn:disabled {
|
||
background: #d9d9d9;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.loading-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(255, 255, 255, 0.8);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
font-size: 16px;
|
||
color: #1890ff;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
#### 3.4 創建 MessageItem 組件
|
||
|
||
**文件**:`src/components/search/MessageItem.vue`(新建)
|
||
|
||
```vue
|
||
<!-- src/components/search/MessageItem.vue -->
|
||
<template>
|
||
<div class="message-item" :class="message.role">
|
||
<div class="message-avatar">
|
||
{{ message.role === 'user' ? '👤' : '🤖' }}
|
||
</div>
|
||
|
||
<div class="message-content">
|
||
<div class="message-header">
|
||
<span class="role-label">{{ message.role === 'user' ? '用戶' : '助手' }}</span>
|
||
<span class="timestamp">{{ formatTime(message.created_at) }}</span>
|
||
</div>
|
||
|
||
<div class="message-text">
|
||
{{ message.content }}
|
||
</div>
|
||
|
||
<div class="message-results" v-if="message.results">
|
||
<div class="results-header">搜索結果:</div>
|
||
<div class="results-content">
|
||
<ResultItem
|
||
v-for="(result, index) in parseResults(message.results)"
|
||
:key="index"
|
||
:result="result"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue';
|
||
import ResultItem from './ResultItem.vue';
|
||
import type { SearchMessage } from '@/types/search';
|
||
|
||
const props = defineProps<{
|
||
message: SearchMessage;
|
||
}>();
|
||
|
||
function formatTime(timestamp: string): string {
|
||
const date = new Date(timestamp);
|
||
return date.toLocaleTimeString('zh-TW', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function parseResults(resultsJson: string): Array<{ file: string; score: number }> {
|
||
try {
|
||
return JSON.parse(resultsJson);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.message-item {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.message-item.user {
|
||
flex-direction: row-reverse;
|
||
}
|
||
|
||
.message-avatar {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
background: #f0f0f0;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
font-size: 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.message-content {
|
||
flex: 1;
|
||
max-width: 80%;
|
||
}
|
||
|
||
.message-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
font-size: 12px;
|
||
color: #999;
|
||
}
|
||
|
||
.message-text {
|
||
padding: 12px 16px;
|
||
background: #f5f5f5;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.message-item.user .message-text {
|
||
background: #1890ff;
|
||
color: white;
|
||
}
|
||
|
||
.message-results {
|
||
margin-top: 12px;
|
||
padding: 12px;
|
||
background: #fafafa;
|
||
border-radius: 8px;
|
||
border: 1px solid #e0e0e0;
|
||
}
|
||
|
||
.results-header {
|
||
font-size: 12px;
|
||
color: #999;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.results-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 4:視圖整合(30 分鐘)
|
||
|
||
#### 4.1 修改 SearchView.vue
|
||
|
||
**文件**:`src/views/SearchView.vue`(修改)
|
||
|
||
```vue
|
||
<!-- src/views/SearchView.vue -->
|
||
<template>
|
||
<div class="search-view">
|
||
<SessionList
|
||
:sessions="sessions"
|
||
:current-session="currentSession"
|
||
:is-loading="isLoading"
|
||
:error="error"
|
||
@create-new="handleCreateNew"
|
||
@select="handleSelectSession"
|
||
@update="handleUpdateSession"
|
||
@delete="handleDeleteSession"
|
||
/>
|
||
|
||
<ChatArea
|
||
:current-session="currentSession"
|
||
:messages="messages"
|
||
:is-loading="isLoading"
|
||
@send="handleSendMessage"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { useSearchSession } from '@/composables/useSearchSession';
|
||
import SessionList from '@/components/search/SessionList.vue';
|
||
import ChatArea from '@/components/search/ChatArea.vue';
|
||
|
||
const {
|
||
sessions,
|
||
currentSession,
|
||
messages,
|
||
isLoading,
|
||
error,
|
||
createNewSession,
|
||
loadSession,
|
||
sendMessage,
|
||
updateSessionTitle,
|
||
deleteSession,
|
||
} = useSearchSession();
|
||
|
||
async function handleCreateNew() {
|
||
const title = `搜尋 ${new Date().toLocaleDateString('zh-TW')}`;
|
||
await createNewSession(title, 'keyword');
|
||
}
|
||
|
||
async function handleSelectSession(sessionId: string) {
|
||
await loadSession(sessionId);
|
||
}
|
||
|
||
async function handleSendMessage(content: string, mode: string) {
|
||
await sendMessage(content, mode as 'keyword' | 'visual' | 'semantic');
|
||
}
|
||
|
||
async function handleUpdateSession(sessionId: string, title: string) {
|
||
await updateSessionTitle(sessionId, title);
|
||
}
|
||
|
||
async function handleDeleteSession(sessionId: string) {
|
||
await deleteSession(sessionId);
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.search-view {
|
||
display: flex;
|
||
height: 100vh;
|
||
background: #fafafa;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
---
|
||
|
||
### 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<SearchHistoryItem> {
|
||
// ...
|
||
}
|
||
|
||
// 移除後
|
||
// (刪除整個函數)
|
||
```
|
||
|
||
---
|
||
|
||
### 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 團隊 |