feat: collapsible AI panel with overlay layout
This commit is contained in:
@@ -143,9 +143,10 @@ onMounted(() => restoreOrLoad())
|
|||||||
@delete-scene="delNode"
|
@delete-scene="delNode"
|
||||||
@close="store.selectedNodeId = null"
|
@close="store.selectedNodeId = null"
|
||||||
/>
|
/>
|
||||||
<AIPanel v-if="store.showAIPanel" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AIPanel v-if="store.showAIPanel" />
|
||||||
|
|
||||||
<input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
|
<input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -158,7 +159,7 @@ html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a16; c
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.editor-layout { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
.editor-layout { width: 100%; height: 100%; display: flex; flex-direction: column; position: relative; }
|
||||||
.toolbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #111122; border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; }
|
.toolbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #111122; border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; }
|
||||||
.toolbar-title { font-size: 15px; font-weight: 600; color: #ddd; letter-spacing: 1px; margin-right: 16px; }
|
.toolbar-title { font-size: 15px; font-weight: 600; color: #ddd; letter-spacing: 1px; margin-right: 16px; }
|
||||||
.toolbar-actions { display: flex; gap: 8px; }
|
.toolbar-actions { display: flex; gap: 8px; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, onMounted } from 'vue'
|
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||||
import { useEditorStore } from '../stores/editorStore'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
import { sendAIRequest } from '../composables/useAI'
|
import { sendAIRequest } from '../composables/useAI'
|
||||||
|
|
||||||
@@ -12,6 +12,13 @@ const chatRef = ref<HTMLDivElement | null>(null)
|
|||||||
|
|
||||||
const mode = ref<'json' | 'code'>('json')
|
const mode = ref<'json' | 'code'>('json')
|
||||||
|
|
||||||
|
const lastMsg = computed(() => {
|
||||||
|
const m = messages.value[messages.value.length - 1]
|
||||||
|
if (!m) return ''
|
||||||
|
const text = m.role === 'user' ? m.content : m.content.substring(0, 60)
|
||||||
|
return text.length > 60 ? text + '...' : text
|
||||||
|
})
|
||||||
|
|
||||||
function toggleMode() {
|
function toggleMode() {
|
||||||
mode.value = mode.value === 'json' ? 'code' : 'json'
|
mode.value = mode.value === 'json' ? 'code' : 'json'
|
||||||
}
|
}
|
||||||
@@ -81,96 +88,142 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ai-panel">
|
<div class="ai-panel" v-if="store.showAIPanel">
|
||||||
<div class="ai-header">
|
<Transition name="slide-up">
|
||||||
<span class="ai-title">AI 助手</span>
|
<div v-if="!store.aiCollapsed" class="ai-expanded">
|
||||||
<div class="ai-header-actions">
|
<div class="ai-exp-header">
|
||||||
<button class="ai-mode-btn" @click="toggleMode" :title="mode === 'json' ? '切换到代码模式' : '切换到 JSON 模式'">
|
<span class="ai-exp-title">AI 助手</span>
|
||||||
{{ mode === 'json' ? '📋 JSON' : '💻 代码' }}
|
<div class="ai-exp-actions">
|
||||||
</button>
|
<button class="ai-mode-btn" @click="toggleMode" :title="mode === 'json' ? '切换到代码模式' : '切换到 JSON 模式'">
|
||||||
<button class="ai-new-btn" @click="newSession" title="新建对话">+</button>
|
{{ mode === 'json' ? '📋 JSON' : '💻 代码' }}
|
||||||
<button class="ai-close-btn" @click="store.showAIPanel = false">✕</button>
|
</button>
|
||||||
</div>
|
<button class="ai-new-btn" @click="newSession" title="新建对话">+</button>
|
||||||
</div>
|
<button v-if="store.aiChanges" class="ai-clear-btn" @click="store.clearAIMarkers()">清除高亮</button>
|
||||||
|
<select v-if="store.versions.length > 0" class="ai-version-select" @change="store.restoreVersion(($event.target as HTMLSelectElement).selectedIndex)">
|
||||||
|
<option disabled selected>版本</option>
|
||||||
|
<option v-for="v in store.versions" :key="v.timestamp">
|
||||||
|
{{ new Date(v.timestamp).toLocaleString('zh-CN') }} {{ v.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="ai-collapse-btn" @click="store.aiCollapsed = true">▼ 折叠</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="store.versions.length > 0 || store.aiChanges" class="ai-toolbar">
|
<div class="ai-chat" ref="chatRef">
|
||||||
<select v-if="store.versions.length > 0" class="ai-version-select" @change="store.restoreVersion(($event.target as HTMLSelectElement).selectedIndex)">
|
<div v-if="!store.deepseekKey" class="ai-key-setup">
|
||||||
<option disabled selected>历史版本</option>
|
<span class="key-label">DeepSeek API Key</span>
|
||||||
<option v-for="v in store.versions" :key="v.timestamp">
|
<input
|
||||||
{{ new Date(v.timestamp).toLocaleString('zh-CN') }} {{ v.label }}
|
class="key-input"
|
||||||
</option>
|
type="password"
|
||||||
</select>
|
placeholder="sk-..."
|
||||||
<button v-if="store.aiChanges" class="ai-clear-btn" @click="store.clearAIMarkers()">清除高亮</button>
|
:value="store.deepseekKey"
|
||||||
</div>
|
@input="store.setDeepseekKey(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<div class="key-hint">Key 保存在浏览器本地,不会上传到编辑器服务器</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="messages.length === 0 && store.deepseekKey" class="ai-empty">
|
||||||
|
输入你的需求,AI 将根据引擎规范修改配置
|
||||||
|
</div>
|
||||||
|
<div v-for="(m, i) in messages" :key="i" class="ai-msg" :class="m.role">
|
||||||
|
<span class="ai-role">{{ m.role === 'user' ? '👤' : '🤖' }}</span>
|
||||||
|
<span class="ai-content">{{ m.content }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="ai-msg assistant">
|
||||||
|
<span class="ai-role">🤖</span>
|
||||||
|
<span class="ai-content loading-dots">正在生成<span class="dots">...</span></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMsg" class="ai-error">{{ errorMsg }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ai-chat" ref="chatRef">
|
<div class="ai-input-area">
|
||||||
<div v-if="!store.deepseekKey" class="ai-key-setup">
|
<input
|
||||||
<span class="key-label">DeepSeek API Key</span>
|
v-model="inputText"
|
||||||
<input
|
class="ai-input"
|
||||||
class="key-input"
|
placeholder="输入你的需求..."
|
||||||
type="password"
|
:disabled="loading"
|
||||||
placeholder="sk-..."
|
@keydown="onKeydown"
|
||||||
:value="store.deepseekKey"
|
/>
|
||||||
@input="store.setDeepseekKey(($event.target as HTMLInputElement).value)"
|
<button class="ai-send-btn" @click="send" :disabled="loading">发送</button>
|
||||||
/>
|
</div>
|
||||||
<div class="key-hint">Key 保存在浏览器本地,不会上传到编辑器服务器</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="messages.length === 0 && store.deepseekKey" class="ai-empty">
|
</Transition>
|
||||||
输入你的需求,AI 将根据引擎规范修改配置
|
|
||||||
</div>
|
|
||||||
<div v-for="(m, i) in messages" :key="i" class="ai-msg" :class="m.role">
|
|
||||||
<span class="ai-role">{{ m.role === 'user' ? '👤' : '🤖' }}</span>
|
|
||||||
<span class="ai-content">{{ m.content }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="loading" class="ai-msg assistant">
|
|
||||||
<span class="ai-role">🤖</span>
|
|
||||||
<span class="ai-content loading-dots">正在生成<span class="dots">...</span></span>
|
|
||||||
</div>
|
|
||||||
<div v-if="errorMsg" class="ai-error">{{ errorMsg }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-input-area">
|
<div class="ai-collapsed-bar" @click="store.aiCollapsed = false">
|
||||||
<input
|
<span class="ai-bar-icon">🤖</span>
|
||||||
v-model="inputText"
|
<span class="ai-bar-title">AI 助手</span>
|
||||||
class="ai-input"
|
<span class="ai-bar-divider">·</span>
|
||||||
placeholder="输入你的需求..."
|
<span class="ai-bar-msg">{{ messages.length > 0 ? lastMsg : '点击开始对话' }}</span>
|
||||||
:disabled="loading"
|
<span class="ai-bar-arrow">▲</span>
|
||||||
@keydown="onKeydown"
|
|
||||||
/>
|
|
||||||
<button class="ai-send-btn" @click="send" :disabled="loading">发送</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ai-panel {
|
.ai-panel {
|
||||||
width: 340px;
|
flex-shrink: 0;
|
||||||
height: 100%;
|
position: relative;
|
||||||
background: #141428;
|
|
||||||
border-left: 1px solid rgba(255,255,255,0.08);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-header {
|
.ai-collapsed-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #161633;
|
||||||
|
border-top: 1px solid rgba(100,200,255,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ai-collapsed-bar:hover {
|
||||||
|
background: #1a1a3a;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-bar-icon { font-size: 13px; }
|
||||||
|
.ai-bar-title { font-weight: 500; color: #8cf; }
|
||||||
|
.ai-bar-divider { color: rgba(255,255,255,0.15); }
|
||||||
|
.ai-bar-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #777; }
|
||||||
|
.ai-bar-arrow { color: #555; font-size: 10px; }
|
||||||
|
.ai-collapsed-bar:hover .ai-bar-arrow { color: #8cf; }
|
||||||
|
|
||||||
|
.ai-expanded {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 40vh;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(20, 20, 44, 0.98);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: 0 -6px 24px rgba(0,0,0,0.5);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-exp-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 16px;
|
padding: 8px 16px;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-title {
|
.ai-exp-title {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-header-actions {
|
.ai-exp-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-mode-btn {
|
.ai-mode-btn {
|
||||||
@@ -184,7 +237,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.ai-mode-btn:hover { background: rgba(100,200,255,0.15); }
|
.ai-mode-btn:hover { background: rgba(100,200,255,0.15); }
|
||||||
|
|
||||||
.ai-new-btn, .ai-close-btn {
|
.ai-new-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
color: #888;
|
color: #888;
|
||||||
@@ -194,22 +247,57 @@ onMounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.ai-new-btn:hover, .ai-close-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.2); }
|
.ai-new-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
|
.ai-clear-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-clear-btn:hover { color: #fff; border-color: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
|
.ai-version-select {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
max-width: 90px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #ccc;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-collapse-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ai-collapse-btn:hover { color: #ddd; }
|
||||||
|
|
||||||
.ai-chat {
|
.ai-chat {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 14px;
|
padding: 10px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-empty {
|
.ai-empty {
|
||||||
color: #555;
|
color: #555;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 60px;
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-msg {
|
.ai-msg {
|
||||||
@@ -253,14 +341,14 @@ onMounted(() => {
|
|||||||
.ai-input-area {
|
.ai-input-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 10px 14px;
|
padding: 8px 14px 10px;
|
||||||
border-top: 1px solid rgba(255,255,255,0.06);
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-input {
|
.ai-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px 12px;
|
padding: 7px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
@@ -271,7 +359,7 @@ onMounted(() => {
|
|||||||
.ai-input:focus { border-color: rgba(255,255,255,0.2); }
|
.ai-input:focus { border-color: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
.ai-send-btn {
|
.ai-send-btn {
|
||||||
padding: 8px 14px;
|
padding: 7px 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: rgba(100,200,255,0.15);
|
background: rgba(100,200,255,0.15);
|
||||||
@@ -286,16 +374,16 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 20px 0;
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-label {
|
.key-label {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-input {
|
.key-input {
|
||||||
padding: 8px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
@@ -310,35 +398,13 @@ onMounted(() => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-toolbar {
|
.slide-up-enter-active,
|
||||||
display: flex;
|
.slide-up-leave-active {
|
||||||
align-items: center;
|
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 16px;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
.slide-up-enter-from,
|
||||||
.ai-version-select {
|
.slide-up-leave-to {
|
||||||
flex: 1;
|
max-height: 0;
|
||||||
padding: 4px 8px;
|
opacity: 0;
|
||||||
font-size: 11px;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #ccc;
|
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-clear-btn {
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #ccc;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ai-clear-btn:hover { color: #fff; border-color: rgba(255,255,255,0.2); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
const sourcePath = ref('/scenes/demo.json')
|
const sourcePath = ref('/scenes/demo.json')
|
||||||
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
||||||
const showAIPanel = ref(false)
|
const showAIPanel = ref(false)
|
||||||
|
const aiCollapsed = ref(true)
|
||||||
const aiSessionId = ref('')
|
const aiSessionId = ref('')
|
||||||
const aiChanges = ref<AIDiff | null>(null)
|
const aiChanges = ref<AIDiff | null>(null)
|
||||||
const versions = ref<EditorVersion[]>([])
|
const versions = ref<EditorVersion[]>([])
|
||||||
@@ -234,7 +235,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
||||||
deepseekKey, showAIPanel, aiSessionId, aiChanges, versions,
|
deepseekKey, showAIPanel, aiSessionId, aiCollapsed, aiChanges, versions,
|
||||||
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
||||||
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
||||||
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
|
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
|
||||||
|
|||||||
Reference in New Issue
Block a user