feat: AI assistant panel, editor improvements, vite and package config
This commit is contained in:
286
editor/components/AIPanel.vue
Normal file
286
editor/components/AIPanel.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useEditorStore } from '../stores/editorStore'
|
||||
import { sendAIRequest } from '../composables/useAI'
|
||||
|
||||
const store = useEditorStore()
|
||||
const inputText = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const messages = ref<{ role: string; content: string }[]>([])
|
||||
const chatRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const mode = ref<'json' | 'code'>('json')
|
||||
|
||||
watch(() => store.selectedNodeId, (id) => {
|
||||
mode.value = id ? 'json' : 'code'
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'json' ? 'code' : 'json'
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const msg = inputText.value.trim()
|
||||
if (!msg || loading.value) return
|
||||
inputText.value = ''
|
||||
errorMsg.value = ''
|
||||
|
||||
if (!store.deepseekKey) {
|
||||
errorMsg.value = '请先在设置中输入 DeepSeek API Key'
|
||||
return
|
||||
}
|
||||
|
||||
store.ensureAISession()
|
||||
messages.value.push({ role: 'user', content: msg })
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const { result } = await sendAIRequest(store.aiSessionId, msg, mode.value, store.deepseekKey)
|
||||
messages.value.push({ role: 'assistant', content: mode.value === 'json' ? '已生成 JSON,请查看编辑器面板' : '代码已修改,请查看预览窗口' })
|
||||
|
||||
if (mode.value === 'json') {
|
||||
// Try to extract pure JSON
|
||||
const clean = result.replace(/^```json\n?|\n?```$/g, '').trim()
|
||||
store.setAIResult(clean)
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e.message || '请求失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
chatRef.value?.scrollTo({ top: chatRef.value.scrollHeight })
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
store.newAISession()
|
||||
messages.value = []
|
||||
errorMsg.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ai-panel">
|
||||
<div class="ai-header">
|
||||
<span class="ai-title">AI 助手</span>
|
||||
<div class="ai-header-actions">
|
||||
<button class="ai-mode-btn" @click="toggleMode" :title="mode === 'json' ? '切换到代码模式' : '切换到 JSON 模式'">
|
||||
{{ mode === 'json' ? '📋 JSON' : '💻 代码' }}
|
||||
</button>
|
||||
<button class="ai-new-btn" @click="newSession" title="新建对话">+</button>
|
||||
<button class="ai-close-btn" @click="store.showAIPanel = false">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-chat" ref="chatRef">
|
||||
<div v-if="!store.deepseekKey" class="ai-key-setup">
|
||||
<span class="key-label">DeepSeek API Key</span>
|
||||
<input
|
||||
class="key-input"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
:value="store.deepseekKey"
|
||||
@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-input-area">
|
||||
<input
|
||||
v-model="inputText"
|
||||
class="ai-input"
|
||||
placeholder="输入你的需求..."
|
||||
:disabled="loading"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button class="ai-send-btn" @click="send" :disabled="loading">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-panel {
|
||||
width: 340px;
|
||||
height: 100%;
|
||||
background: #141428;
|
||||
border-left: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.ai-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai-mode-btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: #8cf;
|
||||
background: rgba(100,200,255,0.08);
|
||||
border: 1px solid rgba(100,200,255,0.2);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-mode-btn:hover { background: rgba(100,200,255,0.15); }
|
||||
|
||||
.ai-new-btn, .ai-close-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #888;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ai-new-btn:hover, .ai-close-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.2); }
|
||||
|
||||
.ai-chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-empty {
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.ai-msg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ai-role {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-msg.user .ai-content {
|
||||
color: #8cf;
|
||||
}
|
||||
|
||||
.ai-msg.assistant .ai-content {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.loading-dots .dots {
|
||||
animation: dotPulse 1.2s infinite;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 20% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
80%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.ai-error {
|
||||
font-size: 12px;
|
||||
color: #e74c3c;
|
||||
padding: 8px 12px;
|
||||
background: rgba(231,76,60,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-input-area {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
color: #ddd;
|
||||
outline: none;
|
||||
}
|
||||
.ai-input:focus { border-color: rgba(255,255,255,0.2); }
|
||||
|
||||
.ai-send-btn {
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
background: rgba(100,200,255,0.15);
|
||||
border: 1px solid rgba(100,200,255,0.25);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-send-btn:hover { background: rgba(100,200,255,0.25); }
|
||||
.ai-send-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.ai-key-setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.key-label {
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.key-input {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 4px;
|
||||
color: #ddd;
|
||||
outline: none;
|
||||
}
|
||||
.key-input:focus { border-color: rgba(100,200,255,0.3); }
|
||||
|
||||
.key-hint {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,29 @@ const store = useEditorStore()
|
||||
const jsonText = ref('')
|
||||
const errorMsg = ref('')
|
||||
const saved = ref(false)
|
||||
const showAcceptReject = ref(false)
|
||||
const preAIValue = ref('')
|
||||
|
||||
watch(() => store.aiResult, (result) => {
|
||||
if (!result) return
|
||||
preAIValue.value = jsonText.value
|
||||
jsonText.value = result
|
||||
showAcceptReject.value = true
|
||||
errorMsg.value = ''
|
||||
})
|
||||
|
||||
function acceptAI() {
|
||||
showAcceptReject.value = false
|
||||
jsonText.value = jsonText.value
|
||||
store.setAIResult('')
|
||||
onBlur()
|
||||
}
|
||||
|
||||
function rejectAI() {
|
||||
jsonText.value = preAIValue.value
|
||||
showAcceptReject.value = false
|
||||
store.setAIResult('')
|
||||
}
|
||||
|
||||
watch(() => [props.scene, store.gameData] as const, () => {
|
||||
errorMsg.value = ''
|
||||
@@ -56,10 +79,15 @@ function onBlur() {
|
||||
<div class="editor-header">
|
||||
<h3>{{ scene ? scene.id : '全局配置' }}</h3>
|
||||
<div class="header-actions">
|
||||
<span v-if="saved" class="saved-hint">已保存</span>
|
||||
<span v-if="showAcceptReject" class="ai-actions">
|
||||
<button class="ai-accept-btn" @click.stop="acceptAI">接受</button>
|
||||
<button class="ai-reject-btn" @click.stop="rejectAI">撤销</button>
|
||||
</span>
|
||||
<span v-else-if="saved" class="saved-hint">已保存</span>
|
||||
<span v-if="errorMsg" class="error-hint">JSON 错误: {{ errorMsg }}</span>
|
||||
<button v-if="scene" class="icon-btn danger" @click="emit('deleteScene', scene.id)" title="删除场景">删除场景</button>
|
||||
<button v-if="scene" class="icon-btn" @click="emit('close')" title="关闭">关闭</button>
|
||||
<button class="icon-btn" @click="store.showAIPanel = true" title="AI 助手">🤖</button>
|
||||
<button v-if="scene" class="icon-btn danger" @click="emit('deleteScene', scene.id)" title="删除场景">🗑</button>
|
||||
<button v-if="scene" class="icon-btn" @click="emit('close')" title="关闭">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,6 +175,33 @@ function onBlur() {
|
||||
.icon-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.25); }
|
||||
.icon-btn.danger:hover { color: #e74c3c; border-color: #e74c3c; }
|
||||
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai-accept-btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: #4caf50;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-accept-btn:hover { background: #388e3c; }
|
||||
|
||||
.ai-reject-btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: #e74c3c;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-reject-btn:hover { background: #c62828; }
|
||||
|
||||
.json-area {
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
|
||||
Reference in New Issue
Block a user