ai-chat-ui/src/components/modals/ConversationSettingsModal.vue

750 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="conversation-settings-modal">
<!-- 头部 -->
<div class="modal-header">
<div class="header-title">
<MessageSquare :size="22" />
<h3>对话设置</h3>
</div>
<button class="close-btn" @click="close">
<X :size="20" />
</button>
</div>
<!-- 内容 -->
<div class="modal-content">
<!-- 对话信息 -->
<div class="section">
<div class="section-title">对话信息</div>
<!-- 标题 -->
<div class="setting-item vertical">
<label class="setting-label">对话标题</label>
<input
v-model="localSettings.title"
type="text"
class="text-input"
placeholder="输入对话标题"
/>
</div>
<!-- 创建时间 -->
<div class="setting-item">
<span class="setting-label">创建时间</span>
<span class="setting-value">{{
formatDate(conversation?.createdAt)
}}</span>
</div>
<!-- 消息数量 -->
<div class="setting-item">
<span class="setting-label">消息数量</span>
<span class="setting-value"
>{{ conversation?.messages.length || 0 }} 条</span
>
</div>
</div>
<!-- AI 设置 -->
<div class="section">
<div class="section-title">AI 设置</div>
<!-- 模型选择 -->
<div class="setting-item vertical">
<label class="setting-label">AI 模型</label>
<FormSelect
v-model="modelSelect"
:options="modelOptions"
valueProp="label"
@update:model-value="updateSelect"
/>
</div>
<!-- 温度 -->
<div class="setting-item vertical">
<div class="setting-header">
<label class="setting-label">温度</label>
<span class="setting-value">{{
localSettings.temperature.toFixed(1)
}}</span>
</div>
<FormSlider
v-model="localSettings.temperature"
:min="0"
:max="1"
:step="0.1"
/>
<p class="setting-hint">
较低的温度使回复更加集中和确定,较高的温度使回复更加多样化
</p>
</div>
<!-- 最大 Token -->
<div class="setting-item vertical">
<div class="setting-header">
<label class="setting-label">最大回复长度</label>
<span class="setting-value">{{
localSettings.maxTokens
}}</span>
</div>
<FormSlider
v-model="localSettings.maxTokens"
:min="256"
:max="8192"
:step="256"
/>
</div>
</div>
<!-- 系统提示词 -->
<div class="section">
<div class="section-title">系统提示词</div>
<div class="setting-item vertical">
<textarea
v-model="localSettings.systemPrompt"
class="prompt-textarea"
rows="5"
placeholder="输入系统提示词,定义 AI 的角色和行为..."
/>
<p class="setting-hint">
系统提示词会影响 AI 在整个对话中的行为方式
</p>
</div>
<!-- 预设提示词 -->
<div class="preset-prompts">
<span class="preset-label">快速选择:</span>
<button
v-for="preset in presetPrompts"
:key="preset.name"
class="preset-btn"
@click="localSettings.systemPrompt = preset.prompt"
>
{{ preset.name }}
</button>
</div>
</div>
<!-- 记忆设置 -->
<div class="section">
<div class="section-title">记忆设置</div>
<div class="setting-item">
<div class="setting-info">
<span class="setting-label">启用对话记忆</span>
<span class="setting-desc">AI 会记住之前的对话内容</span>
</div>
<FormSwitch v-model="localSettings.enableMemory" />
</div>
<div
v-if="localSettings.enableMemory"
class="setting-item vertical"
>
<div class="setting-header">
<label class="setting-label">记忆长度</label>
<span class="setting-value"
>{{ localSettings.memoryLength }} 条消息</span
>
</div>
<FormSlider
v-model="localSettings.memoryLength"
:min="2"
:max="50"
:step="2"
/>
</div>
</div>
<!-- 危险操作 -->
<div class="section danger-section">
<div class="section-title">危险操作</div>
<div class="danger-actions">
<button class="danger-btn" @click="handleClearMessages">
<Trash2 :size="18" />
<span>清空消息</span>
</button>
<button class="danger-btn" @click="handleDeleteConversation">
<Trash2 :size="18" />
<span>删除对话</span>
</button>
</div>
</div>
</div>
<!-- 底部 -->
<div class="modal-footer">
<button class="cancel-btn" @click="close">取消</button>
<button class="save-btn" @click="handleSave">
<Check :size="18" />
<span>保存设置</span>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import FormSwitch from "@/components/ui/FormSwitch.vue";
import FormSlider from "@/components/ui/FormSlider.vue";
import FormSelect from "@/components/ui/FormSelect.vue";
import { MessageSquare, X, Check, Trash2 } from "@/components/icons";
import { chatApi } from "@/services/api.ts";
import type { ConversationSettings } from "@/types/chat";
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
// 分开获取响应式状态用 storeToRefs普通数据直接从 store 获取
const { currentConversation: conversation } = storeToRefs(chatStore);
const { showConversationSettingsModal: visible, settings } =
storeToRefs(settingsStore);
const availableModels: any = ref([]);
const modelSelect = ref(localStorage.getItem("modelSelect") || "");
onMounted(() => {
chatApi.getModels().then((res: any) => {
availableModels.value = res;
if (!localStorage.getItem("modelSelect")) {
modelSelect.value = availableModels.value[0]["name"] || "";
localStorage.setItem("modelSelect", modelSelect.value);
}
});
});
// 本地设置副本
const localSettings = ref({
title: "",
model: "gpt-4",
temperature: 0.7,
maxTokens: 4096,
systemPrompt: "",
enableMemory: true,
memoryLength: 20,
});
// 模型选项 - 添加安全检查
const modelOptions = computed(() => {
if (!availableModels.value || !Array.isArray(availableModels.value)) {
return [{ value: "gpt-4", label: "GPT-4", description: "默认模型" }];
}
return availableModels.value?.map((model: any) => ({
value: model.name,
label: model.name,
description: model.description,
}));
});
// 预设提示词
const presetPrompts = [
{
name: "默认助手",
prompt: "你是一个有帮助的 AI 助手,能够回答各种问题并提供帮助。",
},
{
name: "代码专家",
prompt:
"你是一个专业的编程助手,擅长代码编写、调试和解释。请提供简洁、高质量的代码示例。",
},
{
name: "写作助手",
prompt:
"你是一个专业的写作助手,擅长文章撰写、润色和编辑。请使用优美的语言和清晰的结构。",
},
{
name: "翻译专家",
prompt: "你是一个专业的翻译助手,能够在多种语言之间进行准确、自然的翻译。",
},
{
name: "学习导师",
prompt:
"你是一个耐心的学习导师,擅长用简单易懂的方式解释复杂的概念,并提供练习建议。",
},
];
function updateSelect(data: any) {
localStorage.setItem("modelSelect", data);
}
// 格式化日期
function formatDate(timestamp?: number): string {
if (!timestamp) return "-";
const date = new Date(timestamp);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// 监听对话变化,初始化设置
watch(
[visible, conversation],
([isVisible, conv]) => {
if (isVisible && conv) {
localSettings.value = {
title: conv.title || "",
model: conv.settings?.model || settings.value.defaultModel || "gpt-4",
temperature:
conv.settings?.temperature ??
settings.value.defaultTemperature ??
0.7,
maxTokens:
conv.settings?.maxTokens || settings.value.defaultMaxTokens || 4096,
systemPrompt:
conv.settings?.systemPrompt ||
settings.value.defaultSystemPrompt ||
"",
enableMemory: conv.settings?.enableMemory ?? true,
memoryLength: conv.settings?.memoryLength || 20,
};
}
},
{ immediate: true },
);
function close() {
settingsStore.closeConversationSettingsModal();
}
function handleSave() {
if (!conversation.value) return;
// 更新对话标题
if (localSettings.value.title !== conversation.value.title) {
chatStore.renameConversation(
conversation.value.id,
localSettings.value.title,
);
}
// 更新对话设置 - 这里需要一个新方法来更新对话设置
const convSettings: ConversationSettings = {
model: localSettings.value.model,
temperature: localSettings.value.temperature,
maxTokens: localSettings.value.maxTokens,
systemPrompt: localSettings.value.systemPrompt,
enableMemory: localSettings.value.enableMemory,
memoryLength: localSettings.value.memoryLength,
};
// 调用更新对话设置的方法
chatStore.updateConversationSettings(conversation.value.id, convSettings);
close();
// 显示成功提示
if (window.$toast) {
window.$toast("设置已保存", "success");
}
}
function handleClearMessages() {
if (!conversation.value) return;
if (confirm("确定要清空所有消息吗?此操作不可恢复。")) {
chatStore.clearConversation(conversation.value.id);
close();
}
}
function handleDeleteConversation() {
if (!conversation.value) return;
if (confirm("确定要删除这个对话吗?此操作不可恢复。")) {
chatStore.deleteConversation(conversation.value.id);
close();
}
}
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.conversation-settings-modal {
width: 520px;
max-width: 90vw;
max-height: 85vh;
background: white;
border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
.dark & {
background: #1e1e2e;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #2d2d3d;
}
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
svg {
color: #3b82f6;
}
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1f2937;
.dark & {
background: #374151;
color: #f3f4f6;
}
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.section {
margin-bottom: 28px;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
&.vertical {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
}
.setting-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.setting-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.setting-label {
font-size: 14px;
font-weight: 500;
color: #1f2937;
.dark & {
color: #f3f4f6;
}
}
.setting-desc {
font-size: 12px;
color: #9ca3af;
}
.setting-value {
font-size: 14px;
color: #6b7280;
.setting-header & {
font-weight: 500;
color: #3b82f6;
}
}
.setting-hint {
margin: 0;
font-size: 12px;
color: #9ca3af;
line-height: 1.5;
}
.text-input {
width: 100%;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
font-size: 14px;
color: #1f2937;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.prompt-textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: white;
font-family: inherit;
font-size: 14px;
color: #1f2937;
resize: vertical;
min-height: 120px;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #f3f4f6;
}
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #9ca3af;
}
}
.preset-prompts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.preset-label {
font-size: 12px;
color: #9ca3af;
}
.preset-btn {
padding: 6px 12px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: white;
color: #6b7280;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: #2d2d3d;
border-color: #374151;
color: #9ca3af;
}
&:hover {
border-color: #3b82f6;
color: #3b82f6;
}
}
.danger-section {
padding: 16px;
background: rgba(239, 68, 68, 0.05);
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.1);
}
.danger-actions {
display: flex;
gap: 12px;
}
.danger-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid #fecaca;
border-radius: 10px;
background: white;
color: #ef4444;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
background: transparent;
border-color: rgba(239, 68, 68, 0.3);
}
&:hover {
background: #ef4444;
border-color: #ef4444;
color: white;
}
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
.dark & {
border-top-color: #2d2d3d;
}
}
.cancel-btn {
padding: 10px 20px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: transparent;
color: #6b7280;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
.dark & {
border-color: #374151;
color: #9ca3af;
}
&:hover {
background: #f3f4f6;
color: #374151;
.dark & {
background: #2d2d3d;
color: #e5e7eb;
}
}
}
.save-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
}
// 动画
.modal-enter-active,
.modal-leave-active {
transition: all 0.25s ease;
.conversation-settings-modal {
transition: all 0.25s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.conversation-settings-modal {
transform: scale(0.9);
opacity: 0;
}
}
</style>