diff --git a/app/Http/Controllers/Api/AssistantController.php b/app/Http/Controllers/Api/AssistantController.php index 3a7f54285..bce68fc57 100644 --- a/app/Http/Controllers/Api/AssistantController.php +++ b/app/Http/Controllers/Api/AssistantController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Models\AiAssistantSession; use App\Models\User; use App\Module\AI; use App\Module\Apps; @@ -155,4 +156,152 @@ class AssistantController extends AbstractController } return $dotProduct / $denominator; } + + /** + * 获取会话列表 + */ + public function session__list() + { + $user = User::auth(); + $sessionKey = trim(Request::input('session_key', 'default')); + + $sessions = AiAssistantSession::where('userid', $user->userid) + ->where('session_key', $sessionKey) + ->orderByDesc('updated_at') + ->get(); + + $list = []; + foreach ($sessions as $session) { + $data = Base::json2array($session->data); + $images = Base::json2array($session->images); + foreach ($images as $imageId => $path) { + $images[$imageId] = Base::fillUrl($path); + } + $list[] = [ + 'id' => $session->session_id, + 'title' => $session->title, + 'responses' => $data, + 'images' => $images, + 'sceneKey' => $session->scene_key, + 'createdAt' => $session->created_at ? $session->created_at->getTimestampMs() : 0, + 'updatedAt' => $session->updated_at ? $session->updated_at->getTimestampMs() : 0, + ]; + } + + return Base::retSuccess('success', $list); + } + + /** + * 保存会话 + */ + public function session__save() + { + $user = User::auth(); + $sessionKey = trim(Request::input('session_key', 'default')); + $sessionId = trim(Request::input('session_id', '')); + $sceneKey = trim(Request::input('scene_key', '')); + $title = trim(Request::input('title', '')); + $data = Request::input('data', []); + $newImages = Request::input('new_images', []); + + if (empty($sessionId)) { + return Base::retError('session_id 不能为空'); + } + + $newImageUrls = []; + if (is_array($newImages)) { + $path = 'uploads/assistant/' . date('Ym') . '/' . $user->userid . '/'; + foreach ($newImages as $img) { + $imageId = $img['imageId'] ?? ''; + $dataUrl = $img['dataUrl'] ?? ''; + if (empty($imageId) || empty($dataUrl)) { + continue; + } + $result = Base::image64save([ + 'image64' => $dataUrl, + 'path' => $path, + 'autoThumb' => false, + ]); + if (Base::isSuccess($result)) { + $newImageUrls[$imageId] = $result['data']['path']; + } + } + } + + $session = AiAssistantSession::where('userid', $user->userid) + ->where('session_key', $sessionKey) + ->where('session_id', $sessionId) + ->first(); + + $imageMap = $newImageUrls; + if ($session) { + $existingImages = Base::json2array($session->images); + $imageMap = array_merge($existingImages, $newImageUrls); + } + + $session = AiAssistantSession::createInstance([ + 'userid' => $user->userid, + 'session_key' => $sessionKey, + 'session_id' => $sessionId, + 'scene_key' => $sceneKey, + 'title' => mb_substr($title, 0, 255), + 'data' => Base::array2json(is_array($data) ? $data : []), + 'images' => Base::array2json($imageMap), + ], $session?->id); + $session->save(); + + // 仅返回本次新增的图片URL + $urls = []; + foreach ($newImageUrls as $imageId => $path) { + $urls[$imageId] = Base::fillUrl($path); + } + + return Base::retSuccess('success', [ + 'image_urls' => $urls, + ]); + } + + /** + * 删除会话 + */ + public function session__delete() + { + $user = User::auth(); + $sessionKey = trim(Request::input('session_key', 'default')); + $sessionId = trim(Request::input('session_id', '')); + $clearAll = Request::input('clear_all', false); + + $query = AiAssistantSession::where('userid', $user->userid) + ->where('session_key', $sessionKey); + + if ($clearAll) { + $sessions = $query->get(); + foreach ($sessions as $session) { + $this->deleteSessionImages($session); + } + $query->delete(); + } else { + if (empty($sessionId)) { + return Base::retError('session_id 不能为空'); + } + $session = $query->where('session_id', $sessionId)->first(); + if ($session) { + $this->deleteSessionImages($session); + $session->delete(); + } + } + + return Base::retSuccess('success'); + } + + private function deleteSessionImages(AiAssistantSession $session) + { + $images = Base::json2array($session->images); + foreach ($images as $path) { + $fullPath = public_path($path); + if (file_exists($fullPath)) { + @unlink($fullPath); + } + } + } } diff --git a/app/Models/AiAssistantSession.php b/app/Models/AiAssistantSession.php new file mode 100644 index 000000000..ca8c0c0e4 --- /dev/null +++ b/app/Models/AiAssistantSession.php @@ -0,0 +1,22 @@ +bigIncrements('id'); + $table->bigInteger('userid')->default(0)->comment('用户ID'); + $table->string('session_key', 100)->default('')->comment('场景分类key'); + $table->string('session_id', 100)->default('')->comment('前端生成的会话ID'); + $table->string('scene_key', 200)->default('')->comment('具体场景标识'); + $table->string('title', 255)->default('')->comment('会话标题'); + $table->longText('data')->nullable()->comment('responses JSON'); + $table->longText('images')->nullable()->comment('图片映射 {imageId: relativePath}'); + $table->timestamps(); + $table->index('userid', 'idx_userid'); + $table->unique(['userid', 'session_key', 'session_id'], 'uk_user_session'); + }); + } + + public function down() + { + Schema::dropIfExists('ai_assistant_sessions'); + } +} diff --git a/resources/assets/js/components/AIAssistant/index.vue b/resources/assets/js/components/AIAssistant/index.vue index 21e4f8624..ac46b5b49 100644 --- a/resources/assets/js/components/AIAssistant/index.vue +++ b/resources/assets/js/components/AIAssistant/index.vue @@ -322,6 +322,7 @@ export default { maxImages: 5, // 最大图片数量 imageCacheKeyPrefix: 'aiAssistant.images', // 图片缓存 key 前缀 imageCache: {}, // 内存中的图片缓存 {imageId: dataUrl} + serverImageMap: {}, // 服务端返回的 {imageId: url} 映射 isDragging: false, // 是否正在拖放图片 dragCounter: 0, // 拖放计数器(处理嵌套元素) @@ -335,6 +336,9 @@ export default { this.refreshWelcomePromptsDebounced = debounce(() => { this.displayWelcomePrompts = getWelcomePrompts(this.$store, this.$route?.params || {}); }, 100); + this.saveSessionStoreDebounced = debounce(() => { + this.saveSessionStore(); + }, 2000); }, mounted() { emitter.on('openAIAssistant', this.onOpenAIAssistant); @@ -1274,29 +1278,28 @@ export default { // ==================== 会话管理方法 ==================== - /** - * 获取指定场景的缓存 key - */ - getSessionCacheKey(sessionKey) { - return `${this.sessionCacheKeyPrefix}_${sessionKey || 'default'}`; - }, - /** * 加载指定场景的会话数据 */ async loadSessionStore(sessionKey) { - const cacheKey = this.getSessionCacheKey(sessionKey); try { - const stored = await $A.IDBString(cacheKey); - if (stored) { - this.sessionStore = JSON.parse(stored); - if (!Array.isArray(this.sessionStore)) { - this.sessionStore = []; - } + const {data} = await this.$store.dispatch("call", { + url: 'assistant/session/list', + data: {session_key: sessionKey}, + }); + if (Array.isArray(data)) { + this.sessionStore = data; + // 缓存服务端返回的图片URL映射 + data.forEach(session => { + if (session.images) { + Object.assign(this.serverImageMap, session.images); + } + }); } else { this.sessionStore = []; } } catch (e) { + console.warn('[AIAssistant] 加载会话失败:', e); this.sessionStore = []; } this.sessionStoreLoaded = true; @@ -1305,12 +1308,42 @@ export default { /** * 持久化当前场景的会话数据 */ - saveSessionStore() { - const cacheKey = this.getSessionCacheKey(this.currentSessionKey); + async saveSessionStore() { + if (!this.currentSessionId) return; + const session = this.sessionStore.find(s => s.id === this.currentSessionId); + if (!session) return; + + // 收集本次需要上传的新图片(在 imageCache 中有 base64 但 serverImageMap 中没有的) + const newImages = []; + const imageIds = this.extractImageIdsFromSession(session); + for (const imageId of imageIds) { + if (!this.serverImageMap[imageId] && this.imageCache[imageId]) { + newImages.push({ + imageId, + dataUrl: this.imageCache[imageId], + }); + } + } + try { - $A.IDBSave(cacheKey, JSON.stringify(this.sessionStore)); + const {data} = await this.$store.dispatch("call", { + url: 'assistant/session/save', + method: 'post', + data: { + session_key: this.currentSessionKey, + session_id: session.id, + scene_key: session.sceneKey || '', + title: session.title || '', + data: session.responses || [], + new_images: newImages, + }, + }); + // 更新服务端图片映射 + if (data?.image_urls) { + Object.assign(this.serverImageMap, data.image_urls); + } } catch (e) { - console.warn('[AIAssistant] Failed to save session store:', e); + console.warn('[AIAssistant] 保存会话失败:', e); } }, @@ -1448,7 +1481,7 @@ export default { this.sessionStore.splice(this.maxSessionsPerKey); } - this.saveSessionStore(); + this.saveSessionStoreDebounced(); }, /** @@ -1492,11 +1525,16 @@ export default { const index = this.sessionStore.findIndex(s => s.id === sessionId); if (index > -1) { const session = this.sessionStore[index]; - // 清理会话相关的图片缓存 this.clearSessionImageCache(session); this.sessionStore.splice(index, 1); - this.saveSessionStore(); - // 如果删除的是当前会话,创建新会话 + this.$store.dispatch("call", { + url: 'assistant/session/delete', + method: 'post', + data: { + session_key: this.currentSessionKey, + session_id: sessionId, + }, + }).catch(e => console.warn('[AIAssistant] 删除会话失败:', e)); if (this.currentSessionId === sessionId) { this.createNewSession(false); } @@ -1510,13 +1548,18 @@ export default { $A.modalConfirm({ title: this.$L('清空历史会话'), content: this.$L('确定要清空当前场景的所有历史会话吗?'), - onOk: async () => { - // 清理所有会话的图片缓存 - for (const session of this.sessionStore) { - await this.clearSessionImageCache(session); - } + onOk: () => { + this.serverImageMap = {}; + this.imageCache = {}; this.sessionStore = []; - this.saveSessionStore(); + this.$store.dispatch("call", { + url: 'assistant/session/delete', + method: 'post', + data: { + session_key: this.currentSessionKey, + clear_all: true, + }, + }).catch(e => console.warn('[AIAssistant] 清空会话失败:', e)); this.createNewSession(false); } }); @@ -1929,27 +1972,11 @@ export default { return `${timestamp}_${random}`; }, - /** - * 获取图片缓存 key - */ - getImageCacheKey(imageId) { - return `${this.imageCacheKeyPrefix}_${imageId}`; - }, - /** * 保存图片到独立缓存 */ - async saveImageToCache(imageId, dataUrl) { - const cacheKey = this.getImageCacheKey(imageId); - try { - // 压缩到 512px 再保存(历史图片不需要高清) - const compressedUrl = await this.resizeDataUrl(dataUrl, 512); - await $A.IDBSave(cacheKey, compressedUrl); - // 同时保存到内存缓存 - this.imageCache[imageId] = compressedUrl; - } catch (e) { - console.warn('[AIAssistant] 图片缓存保存失败:', e); - } + saveImageToCache(imageId, dataUrl) { + this.imageCache[imageId] = dataUrl; }, /** @@ -2000,21 +2027,13 @@ export default { * 从缓存获取图片 */ async getImageFromCache(imageId) { - // 先检查内存缓存 + // 1. 先检查内存缓存 if (this.imageCache[imageId]) { return this.imageCache[imageId]; } - // 从 IndexedDB 获取 - const cacheKey = this.getImageCacheKey(imageId); - try { - const dataUrl = await $A.IDBString(cacheKey); - if (dataUrl) { - // 保存到内存缓存 - this.imageCache[imageId] = dataUrl; - return dataUrl; - } - } catch (e) { - console.warn('[AIAssistant] 图片缓存读取失败:', e); + // 2. 检查服务端URL映射 + if (this.serverImageMap[imageId]) { + return this.serverImageMap[imageId]; } return null; }, @@ -2022,14 +2041,9 @@ export default { /** * 删除单个图片缓存 */ - async deleteImageCache(imageId) { - const cacheKey = this.getImageCacheKey(imageId); - try { - await $A.IDBDel(cacheKey); - delete this.imageCache[imageId]; - } catch (e) { - console.warn('[AIAssistant] 图片缓存删除失败:', e); - } + deleteImageCache(imageId) { + delete this.imageCache[imageId]; + delete this.serverImageMap[imageId]; }, /** @@ -2052,10 +2066,10 @@ export default { /** * 清理会话相关的图片缓存 */ - async clearSessionImageCache(session) { + clearSessionImageCache(session) { const imageIds = this.extractImageIdsFromSession(session); for (const imageId of imageIds) { - await this.deleteImageCache(imageId); + this.deleteImageCache(imageId); } },