mirror of
https://github.com/kuaifan/dootask.git
synced 2026-04-23 10:18:41 +00:00
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:
parent
717e520556
commit
82d2ca6360
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Models\AiAssistantSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Module\AI;
|
use App\Module\AI;
|
||||||
use App\Module\Apps;
|
use App\Module\Apps;
|
||||||
@ -155,4 +156,152 @@ class AssistantController extends AbstractController
|
|||||||
}
|
}
|
||||||
return $dotProduct / $denominator;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/Models/AiAssistantSession.php
Normal file
22
app/Models/AiAssistantSession.php
Normal 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';
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -322,6 +322,7 @@ export default {
|
|||||||
maxImages: 5, // 最大图片数量
|
maxImages: 5, // 最大图片数量
|
||||||
imageCacheKeyPrefix: 'aiAssistant.images', // 图片缓存 key 前缀
|
imageCacheKeyPrefix: 'aiAssistant.images', // 图片缓存 key 前缀
|
||||||
imageCache: {}, // 内存中的图片缓存 {imageId: dataUrl}
|
imageCache: {}, // 内存中的图片缓存 {imageId: dataUrl}
|
||||||
|
serverImageMap: {}, // 服务端返回的 {imageId: url} 映射
|
||||||
isDragging: false, // 是否正在拖放图片
|
isDragging: false, // 是否正在拖放图片
|
||||||
dragCounter: 0, // 拖放计数器(处理嵌套元素)
|
dragCounter: 0, // 拖放计数器(处理嵌套元素)
|
||||||
|
|
||||||
@ -335,6 +336,9 @@ export default {
|
|||||||
this.refreshWelcomePromptsDebounced = debounce(() => {
|
this.refreshWelcomePromptsDebounced = debounce(() => {
|
||||||
this.displayWelcomePrompts = getWelcomePrompts(this.$store, this.$route?.params || {});
|
this.displayWelcomePrompts = getWelcomePrompts(this.$store, this.$route?.params || {});
|
||||||
}, 100);
|
}, 100);
|
||||||
|
this.saveSessionStoreDebounced = debounce(() => {
|
||||||
|
this.saveSessionStore();
|
||||||
|
}, 2000);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
emitter.on('openAIAssistant', this.onOpenAIAssistant);
|
emitter.on('openAIAssistant', this.onOpenAIAssistant);
|
||||||
@ -1274,29 +1278,28 @@ export default {
|
|||||||
|
|
||||||
// ==================== 会话管理方法 ====================
|
// ==================== 会话管理方法 ====================
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取指定场景的缓存 key
|
|
||||||
*/
|
|
||||||
getSessionCacheKey(sessionKey) {
|
|
||||||
return `${this.sessionCacheKeyPrefix}_${sessionKey || 'default'}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载指定场景的会话数据
|
* 加载指定场景的会话数据
|
||||||
*/
|
*/
|
||||||
async loadSessionStore(sessionKey) {
|
async loadSessionStore(sessionKey) {
|
||||||
const cacheKey = this.getSessionCacheKey(sessionKey);
|
|
||||||
try {
|
try {
|
||||||
const stored = await $A.IDBString(cacheKey);
|
const {data} = await this.$store.dispatch("call", {
|
||||||
if (stored) {
|
url: 'assistant/session/list',
|
||||||
this.sessionStore = JSON.parse(stored);
|
data: {session_key: sessionKey},
|
||||||
if (!Array.isArray(this.sessionStore)) {
|
});
|
||||||
this.sessionStore = [];
|
if (Array.isArray(data)) {
|
||||||
}
|
this.sessionStore = data;
|
||||||
|
// 缓存服务端返回的图片URL映射
|
||||||
|
data.forEach(session => {
|
||||||
|
if (session.images) {
|
||||||
|
Object.assign(this.serverImageMap, session.images);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
this.sessionStore = [];
|
this.sessionStore = [];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.warn('[AIAssistant] 加载会话失败:', e);
|
||||||
this.sessionStore = [];
|
this.sessionStore = [];
|
||||||
}
|
}
|
||||||
this.sessionStoreLoaded = true;
|
this.sessionStoreLoaded = true;
|
||||||
@ -1305,12 +1308,42 @@ export default {
|
|||||||
/**
|
/**
|
||||||
* 持久化当前场景的会话数据
|
* 持久化当前场景的会话数据
|
||||||
*/
|
*/
|
||||||
saveSessionStore() {
|
async saveSessionStore() {
|
||||||
const cacheKey = this.getSessionCacheKey(this.currentSessionKey);
|
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 {
|
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) {
|
} 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.sessionStore.splice(this.maxSessionsPerKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saveSessionStore();
|
this.saveSessionStoreDebounced();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1492,11 +1525,16 @@ export default {
|
|||||||
const index = this.sessionStore.findIndex(s => s.id === sessionId);
|
const index = this.sessionStore.findIndex(s => s.id === sessionId);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
const session = this.sessionStore[index];
|
const session = this.sessionStore[index];
|
||||||
// 清理会话相关的图片缓存
|
|
||||||
this.clearSessionImageCache(session);
|
this.clearSessionImageCache(session);
|
||||||
this.sessionStore.splice(index, 1);
|
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) {
|
if (this.currentSessionId === sessionId) {
|
||||||
this.createNewSession(false);
|
this.createNewSession(false);
|
||||||
}
|
}
|
||||||
@ -1510,13 +1548,18 @@ export default {
|
|||||||
$A.modalConfirm({
|
$A.modalConfirm({
|
||||||
title: this.$L('清空历史会话'),
|
title: this.$L('清空历史会话'),
|
||||||
content: this.$L('确定要清空当前场景的所有历史会话吗?'),
|
content: this.$L('确定要清空当前场景的所有历史会话吗?'),
|
||||||
onOk: async () => {
|
onOk: () => {
|
||||||
// 清理所有会话的图片缓存
|
this.serverImageMap = {};
|
||||||
for (const session of this.sessionStore) {
|
this.imageCache = {};
|
||||||
await this.clearSessionImageCache(session);
|
|
||||||
}
|
|
||||||
this.sessionStore = [];
|
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);
|
this.createNewSession(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1929,27 +1972,11 @@ export default {
|
|||||||
return `${timestamp}_${random}`;
|
return `${timestamp}_${random}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取图片缓存 key
|
|
||||||
*/
|
|
||||||
getImageCacheKey(imageId) {
|
|
||||||
return `${this.imageCacheKeyPrefix}_${imageId}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存图片到独立缓存
|
* 保存图片到独立缓存
|
||||||
*/
|
*/
|
||||||
async saveImageToCache(imageId, dataUrl) {
|
saveImageToCache(imageId, dataUrl) {
|
||||||
const cacheKey = this.getImageCacheKey(imageId);
|
this.imageCache[imageId] = dataUrl;
|
||||||
try {
|
|
||||||
// 压缩到 512px 再保存(历史图片不需要高清)
|
|
||||||
const compressedUrl = await this.resizeDataUrl(dataUrl, 512);
|
|
||||||
await $A.IDBSave(cacheKey, compressedUrl);
|
|
||||||
// 同时保存到内存缓存
|
|
||||||
this.imageCache[imageId] = compressedUrl;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[AIAssistant] 图片缓存保存失败:', e);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2000,21 +2027,13 @@ export default {
|
|||||||
* 从缓存获取图片
|
* 从缓存获取图片
|
||||||
*/
|
*/
|
||||||
async getImageFromCache(imageId) {
|
async getImageFromCache(imageId) {
|
||||||
// 先检查内存缓存
|
// 1. 先检查内存缓存
|
||||||
if (this.imageCache[imageId]) {
|
if (this.imageCache[imageId]) {
|
||||||
return this.imageCache[imageId];
|
return this.imageCache[imageId];
|
||||||
}
|
}
|
||||||
// 从 IndexedDB 获取
|
// 2. 检查服务端URL映射
|
||||||
const cacheKey = this.getImageCacheKey(imageId);
|
if (this.serverImageMap[imageId]) {
|
||||||
try {
|
return this.serverImageMap[imageId];
|
||||||
const dataUrl = await $A.IDBString(cacheKey);
|
|
||||||
if (dataUrl) {
|
|
||||||
// 保存到内存缓存
|
|
||||||
this.imageCache[imageId] = dataUrl;
|
|
||||||
return dataUrl;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[AIAssistant] 图片缓存读取失败:', e);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@ -2022,14 +2041,9 @@ export default {
|
|||||||
/**
|
/**
|
||||||
* 删除单个图片缓存
|
* 删除单个图片缓存
|
||||||
*/
|
*/
|
||||||
async deleteImageCache(imageId) {
|
deleteImageCache(imageId) {
|
||||||
const cacheKey = this.getImageCacheKey(imageId);
|
delete this.imageCache[imageId];
|
||||||
try {
|
delete this.serverImageMap[imageId];
|
||||||
await $A.IDBDel(cacheKey);
|
|
||||||
delete this.imageCache[imageId];
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[AIAssistant] 图片缓存删除失败:', e);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2052,10 +2066,10 @@ export default {
|
|||||||
/**
|
/**
|
||||||
* 清理会话相关的图片缓存
|
* 清理会话相关的图片缓存
|
||||||
*/
|
*/
|
||||||
async clearSessionImageCache(session) {
|
clearSessionImageCache(session) {
|
||||||
const imageIds = this.extractImageIdsFromSession(session);
|
const imageIds = this.extractImageIdsFromSession(session);
|
||||||
for (const imageId of imageIds) {
|
for (const imageId of imageIds) {
|
||||||
await this.deleteImageCache(imageId);
|
this.deleteImageCache(imageId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user