feat(ai): AI助手聊天记录服务端持久化

- 图片缓存不再二次压缩,用户预览与AI收到的图片质量一致
- 新增 ai_assistant_sessions 表及 AiAssistantSession 模型
- 新增会话 API:session/list、session/save、session/delete
- 前端会话存储从 IndexedDB 切换为服务端 API,图片落盘到 uploads/assistant/{YYYYMM}/{user_id}/
- saveSessionStore 添加防抖,删除会话时同步清理内存缓存

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-04-17 04:22:35 +00:00
parent 717e520556
commit 82d2ca6360
4 changed files with 286 additions and 68 deletions

View File

@ -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);
}
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
/**
* AI 助手会话
*
* @property int $id
* @property int $userid
* @property string $session_key
* @property string $session_id
* @property string $scene_key
* @property string $title
* @property string|null $data
* @property string|null $images
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class AiAssistantSession extends AbstractModel
{
protected $table = 'ai_assistant_sessions';
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAiAssistantSessionsTable extends Migration
{
public function up()
{
if (Schema::hasTable('ai_assistant_sessions')) {
return;
}
Schema::create('ai_assistant_sessions', function (Blueprint $table) {
$table->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');
}
}

View File

@ -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);
}
},