Files
tianshu-engine/editor/components/AIPanel.vue

411 lines
10 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.

<script setup lang="ts">
import { ref, computed, nextTick, onMounted } 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')
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() {
mode.value = mode.value === 'json' ? 'code' : 'json'
}
function buildMessage(userInput: string): string {
if (mode.value !== 'json') return `需求: ${userInput}`
let ctx = `修改文件 ${store.sourcePath}`
if (store.selectedScene) ctx += `,针对场景节点 ${store.selectedScene.id}`
ctx += `。需求: ${userInput}`
return ctx
}
async function send() {
const userInput = inputText.value.trim()
if (!userInput || loading.value) return
inputText.value = ''
errorMsg.value = ''
if (!store.deepseekKey) {
errorMsg.value = '请先在设置中输入 DeepSeek API Key'
return
}
const fullMessage = buildMessage(userInput)
messages.value.push({ role: 'user', content: userInput })
loading.value = true
if (mode.value === 'json') {
await store.saveVersion('AI 修改前')
}
const oldGameData = mode.value === 'json' ? JSON.parse(JSON.stringify(store.gameData)) : undefined
try {
const { result, sessionId: newSid } = await sendAIRequest(fullMessage, mode.value, store.deepseekKey, store.aiSessionId || undefined)
if (newSid) store.setAISessionId(newSid)
if (mode.value === 'json') {
messages.value.push({ role: 'assistant', content: result || '已完成' })
await store.reloadFromDisk(oldGameData)
} else {
messages.value.push({ role: 'assistant', content: result || '已完成' })
}
} 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.clearAISession()
messages.value = []
errorMsg.value = ''
}
onMounted(() => {
store.loadVersions()
})
</script>
<template>
<div class="ai-panel" v-if="store.showAIPanel">
<Transition name="slide-up">
<div v-if="!store.aiCollapsed" class="ai-expanded">
<div class="ai-exp-header">
<span class="ai-exp-title">AI 助手</span>
<div class="ai-exp-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 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 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>
</Transition>
<div v-if="store.aiCollapsed" class="ai-collapsed-bar" @click="store.aiCollapsed = false">
<span class="ai-bar-icon">🤖</span>
<span class="ai-bar-title">AI 助手</span>
<span class="ai-bar-divider">·</span>
<span class="ai-bar-msg">{{ messages.length > 0 ? lastMsg : '点击开始对话' }}</span>
<span class="ai-bar-arrow"></span>
</div>
</div>
</template>
<style scoped>
.ai-panel {
flex-shrink: 0;
position: relative;
}
.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: 0;
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;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.ai-exp-title {
font-size: 14px;
font-weight: 500;
color: #ddd;
}
.ai-exp-actions {
display: flex;
align-items: center;
gap: 6px;
}
.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 {
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 { 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 {
flex: 1;
overflow-y: auto;
padding: 10px 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 80px;
}
.ai-empty {
color: #555;
font-size: 12px;
text-align: center;
margin-top: 30px;
}
.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: 8px 14px 10px;
border-top: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.ai-input {
flex: 1;
padding: 7px 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: 7px 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: 16px 0;
}
.key-label {
font-size: 13px;
color: #ccc;
}
.key-input {
padding: 6px 10px;
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;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
max-height: 0;
opacity: 0;
}
</style>