feat: AI assistant panel, editor improvements, vite and package config
This commit is contained in:
234
docs/E17_AI_ASSISTANT_PROPOSAL.md
Normal file
234
docs/E17_AI_ASSISTANT_PROPOSAL.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# E17: AI 编码助手 — opencode + DeepSeek 集成
|
||||
|
||||
## 概述
|
||||
|
||||
编辑器内嵌 AI 对话面板,使用 opencode Agent + DeepSeek 后端,支持两种模式:
|
||||
- **JSON 模式** — 修改场景配置,填充 textarea 供用户审查后接受
|
||||
- **代码模式** — 直接修改 `src/` 目录下的 Vue 组件和 CSS,Vite HMR 实时预览
|
||||
|
||||
## 完整架构
|
||||
|
||||
```
|
||||
浏览器 (Editor)
|
||||
├── AIPanel.vue 用户输入自然语言
|
||||
├── NodeEditor.vue 接收 AI 返回的 JSON,[接受]/[撤销]
|
||||
└── App.vue 管理 AI 面板状态
|
||||
|
||||
↓ POST /api/ai { sessionId, userMessage, apiKey, mode, nodeId? }
|
||||
|
||||
Vite 中间件(零状态)
|
||||
└── spawn('node_modules/.bin/opencode', ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage])
|
||||
├── fullMessage = modePrefix + userMessage
|
||||
│ JSON模式: "JSON模式:只返回修改后的 JSON 文本,不要写任何文件。需求:..." + nodeJson
|
||||
│ 代码模式: "代码模式:直接修改 src/ 下的源码文件并保存。需求:..."
|
||||
├── opencode 自身管理会话上下文(--session 复用已有会话)
|
||||
├── 自动读取项目文件构建 prompt → 调 DeepSeek API
|
||||
└── 返回 stdout
|
||||
```
|
||||
|
||||
## JSON 模式交互时序
|
||||
|
||||
```
|
||||
用户 编辑器 Vite 中间件 opencode DeepSeek
|
||||
│ │ │ │ │
|
||||
│ 选中节点+输入需求 │ │ │ │
|
||||
│─────────────────────→│ │ │ │
|
||||
│ │ POST /api/ai │ │ │
|
||||
│ │──────────────────────→│ │ │
|
||||
│ │ │ spawn opencode │ │
|
||||
│ │ │───────────────────→│ │
|
||||
│ │ │ │ 读 story.json + │
|
||||
│ │ │ │ SPEC.md + 用户需求 │
|
||||
│ │ │ │────────────────────→│
|
||||
│ │ │ │ 返回 JSON │
|
||||
│ │ │ │←────────────────────│
|
||||
│ │ │ stdout: JSON │ │
|
||||
│ │ │←───────────────────│ │
|
||||
│ │ 200 { result } │ │ │
|
||||
│ │←──────────────────────│ │ │
|
||||
│ │ │ │ │
|
||||
│ 显示 [接受] [撤销] │ │ │ │
|
||||
│─────→ textarea 填充 │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ 点击 [接受] │ │ │ │
|
||||
│─────────────────────→│ │ │ │
|
||||
│ │ JSON.parse → update │ │ │
|
||||
│ │ → autoSave 写磁盘 │ │ │
|
||||
```
|
||||
|
||||
## 代码模式交互时序
|
||||
|
||||
```
|
||||
用户 编辑器 Vite 中间件 opencode DeepSeek
|
||||
│ │ │ │ │
|
||||
│ 输入 UI 修改需求 │ │ │ │
|
||||
│─────────────────────→│ │ │ │
|
||||
│ │ POST /api/ai │ │ │
|
||||
│ │──────────────────────→│ │ │
|
||||
│ │ │ spawn opencode │ │
|
||||
│ │ │───────────────────→│ │
|
||||
│ │ │ │ 读 src/components/ │
|
||||
│ │ │ │ 改 Vue/CSS 文件 │
|
||||
│ │ │ │────────────────────→│
|
||||
│ │ │ │ 写文件到 src/ │
|
||||
│ │ │ │←────────────────────│
|
||||
│ │ │ stdout: done │ │
|
||||
│ │ │←───────────────────│ │
|
||||
│ │ 200 { result } │ │ │
|
||||
│ │←──────────────────────│ │ │
|
||||
│ │ │ │ │
|
||||
│ Vite HMR 检测变化 │ │ │ │
|
||||
│←─────────────────────│ │ │ │
|
||||
│ 浏览器热更新预览 │ │ │ │
|
||||
```
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
| 决策 | 做法 | 原因 |
|
||||
|------|------|------|
|
||||
| **AI 后端** | opencode Agent + DeepSeek | 唯一覆盖 JSON + 代码双模式;自动读取项目文件构建上下文 |
|
||||
| **opencode 安装** | npm 包 `opencode-ai`,作为 `devDependencies`。`npm install` 后 `node_modules/.bin/opencode` 即可用 | clone 即用,无需手动全局安装 |
|
||||
| **API Key 存储** | 编辑器设置中输入,存 localStorage,每次请求传给 `/api/ai` | 不硬编码,创作者自行管理 |
|
||||
| **API Key 传输** | 通过 POST body 传到 Vite 中间件,不暴露在浏览器网络日志 | XSS 只能获取 localStorage,看不到服务端日志 |
|
||||
| **JSON 编辑** | AI 返回 JSON 填充 textarea,用户审查后 [接受] 才保存 | 保留创作者对故事数据的最终控制权 |
|
||||
| **代码编辑** | opencode 直接写 `src/` 目录,Vite HMR 秒级刷新 | 代码修改即时可视化,不需要手动刷新 |
|
||||
| **上下文构建** | opencode 读取项目文件自成上下文 | 不需要手动拼系统 prompt |
|
||||
| **模式切换** | AIPanel 当前模式标签:选中节点 → "JSON 模式";未选中节点 → "代码模式"。手动可切换 | 减少用户认知负担,大部分场景自动判断正确 |
|
||||
| **DeepSeek 模型** | `--model deepseek`,通过 opencode providers 配置 Key | opencode 自身管理模型路由 |
|
||||
|
||||
## 会话管理
|
||||
|
||||
opencode 原生支持会话:`opencode run --session <id>` 复用已有上下文,`opencode session list` 列出历史。
|
||||
|
||||
### 架构原则:前端持 sessionId,中间件无状态
|
||||
|
||||
```
|
||||
AIPanel (Vue)
|
||||
├── sessionId = localStorage.getItem('editor_ai_session') || crypto.randomUUID()
|
||||
├── 每次请求 POST /api/ai 带上 sessionId
|
||||
├── "新建对话" → 新 UUID → 覆盖 localStorage
|
||||
└── 历史列表 → GET /api/ai/sessions → opencode session list → 前端渲染
|
||||
|
||||
Vite 中间件 — 零状态
|
||||
└── spawn('opencode', ['run', '--session', sessionId, '--format', 'json', userMessage])
|
||||
└── 不存任何 sessionId,不维护子进程
|
||||
```
|
||||
|
||||
### 会话操作
|
||||
|
||||
| 操作 | opencode CLI | 触发方式 |
|
||||
|------|-------------|---------|
|
||||
| 首次对话 | `opencode run --session <新UUID> --model deepseek --format json "..."` | AIPanel 检测 localStorage 无 sessionId |
|
||||
| 继续对话 | `opencode run --session <已有UUID> --model deepseek --format json "..."` | 带上同一 sessionId |
|
||||
| 新建对话 | 前端生成新 UUID → 旧会话仍存在于 opencode 内部 DB | AIPanel "新建对话" 按钮 |
|
||||
| 历史列表 | `opencode session list` | AIPanel 顶部下拉(可选) |
|
||||
|
||||
### 为什么不用长活子进程
|
||||
|
||||
| | 长活子进程 + stdin/stdout | **独立进程 + --session** |
|
||||
|------|:--:|:--:|
|
||||
| 进程管理 | 需管道通信、心跳检测 | `spawn` 等待 exit,0 行管理代码 |
|
||||
| 中间件状态 | 需维护 sessionId→process Map | 零状态 |
|
||||
| dev server 重启 | 会话丢失 | localStorage 持久化,重启可恢复 |
|
||||
| 稳定性 | 子进程可能 crash | 每次独立进程,天然隔离 |
|
||||
| 延迟 | ~1-3s(管道通信) | ~5s(启动 Node + 加载会话),对编辑器 AI 场景可接受 |
|
||||
|
||||
## 安全设计
|
||||
|
||||
| 层级 | 措施 |
|
||||
|------|------|
|
||||
| **API Key** | AES 加密存 localStorage?→ 不用,Key 本身是服务端 API 凭证,浏览器存储已是业界实践(VS Code Copilot、Cursor 同理) |
|
||||
| **传输** | POST body 中传递,HTTPS 加密。仅 `/api/ai` 路由可读取,上游不打印日志 |
|
||||
| **文件写入** | opencode 通过 Vite 中间件 spawn,中间件做路径白名单:JSON 模式只允许 `public/scenes/*.json`;代码模式只允许 `src/**/*.vue`、`src/**/*.ts`、`src/locales/*.json` |
|
||||
| **请求频率** | 中间件加 3s 内去重(同一 mode+userMessage 不重复 spawn) |
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 错误场景 | 处理 |
|
||||
|----------|------|
|
||||
| DeepSeek API 超时(15s) | 中间件 kill 子进程,返回 `504 { error: "timeout" }` |
|
||||
| opencode 进程崩溃(exit code ≠ 0) | 中间件返回 `500 { error: "opencode exited with code " + code }` |
|
||||
| DeepSeek API 余额不足 | 中间件返回 `402 { error: "insufficient_quota" }` |
|
||||
| opencode 返回非 JSON(代码模式) | 直接视为成功(stdout 文本无关紧要) |
|
||||
| opencode 返回非 JSON(JSON 模式) | 中间件尝试用正则提取 JSON 块,无法提取则返回 `500` 给前端 |
|
||||
|
||||
## 文件改动清单
|
||||
|
||||
| 文件 | 职能 |
|
||||
|------|------|
|
||||
| `package.json` | 新增 `opencode-ai` devDependency(`"opencode-ai": "^1.17"`) |
|
||||
| `vite.config.ts` | 新增 `POST /api/ai` 中间件:接收 sessionId/userMessage/apiKey/mode,构造 modePrefix + spawn(`opencode`, `['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage]`);`GET /api/ai/sessions` → `opencode session list` |
|
||||
| `editor/composables/useAI.ts` | **新建** — `sendAIRequest(sessionId, userMessage, mode)` → fetch(`/api/ai`);`listSessions()` → fetch(`/api/ai/sessions`) |
|
||||
| `editor/components/AIPanel.vue` | **新建** — 消息历史 + `<input>` + loading + "正在生成..." + **"新建对话"按钮** + 会话列表下拉 |
|
||||
| `editor/stores/editorStore.ts` | 新增 `deepseekKey`(localStorage)、`showAIPanel`、`aiResult`、`sessionId` |
|
||||
| `editor/App.vue` | 新增 "AI 助手" 按钮 + AIPanel 组件 + 设置面板中 API Key 输入栏 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### T1: JSON 模式 — 正常流程
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| 选中一个场景节点 | NodeEditor 显示该场景 JSON |
|
||||
| 打开 AIPanel,输入"给这个场景添加一个 QTE" | 显示 loading 状态 |
|
||||
| opencode 返回修改后 JSON | NodeEditor textarea 被填充,出现 [接受] [撤销] |
|
||||
| 点击 [接受] | JSON.parse 成功,store.updateScene 执行,autoSave 写磁盘 |
|
||||
| 点击 [撤销] | textarea 回滚到 AI 修改前的值 |
|
||||
|
||||
### T2: JSON 模式 — opencode 返回无效 JSON
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| AI 返回非法 JSON | 中间件返回 `500 { error }` |
|
||||
| AIPanel 显示红色错误提示 | "AI 返回格式异常,请重试",textarea 不被填充 |
|
||||
|
||||
### T3: 代码模式 — 正常流程
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| 未选中任何节点 | AIPanel 显示 "代码模式" 标签 |
|
||||
| 输入"把按钮改成圆角 20px" | 显示 loading |
|
||||
| opencode 修改 `src/components/ChoicePanel.vue` 的 CSS | 文件写磁盘成功 |
|
||||
| Vite HMR 触发 | 预览窗按钮圆角即时可见 |
|
||||
|
||||
### T4: 会话管理
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| 首次对话 | localStorage 无 `editor_ai_session` → AIPanel 自动生成 UUID |
|
||||
| 第二次对话 | 带上同一 sessionId → opencode `--session <id>` 恢复上下文 |
|
||||
| 点击 "新建对话" | 新 UUID 写入 localStorage |
|
||||
| 刷新页面 | sessionId 不丢失,历史列表可查 |
|
||||
|
||||
### T5: 错误处理
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| DeepSeek Key 无效 | AIPanel 显示 "API 认证失败,请检查 Key" |
|
||||
| opencode 进程被 kill | 中间件 `child.on('exit', 137)` → 500 |
|
||||
| 请求 15s 无响应 | 中间件 kill 子进程 → 504 |
|
||||
| 同一 userMessage 3s 内发送两次 | 去重,不重复 spawn |
|
||||
|
||||
### T6: 路径白名单
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| 代码模式让 AI 修改 `/etc/passwd` | 中间件拒绝或 opencode 写盘失败 |
|
||||
| JSON 模式让 AI 修改 `../secret.json` | 路径不在 `public/scenes/*.json` 范围内, 被拒绝 |
|
||||
|
||||
### T7: API Key 管理
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| 未填 Key 就发送请求 | AIPanel 提示 "请先在设置中输入 DeepSeek API Key" |
|
||||
| 填入 Key → 发送请求 | Key 从 localStorage 读取,POST body 传给中间件 |
|
||||
| 刷新页面 | Key 仍存在 |
|
||||
|
||||
### T8: NodeEditor 覆盖保护
|
||||
|
||||
| 步骤 | 预期结果 |
|
||||
|------|---------|
|
||||
| 用户正在手动编辑 textarea → AI 返回结果 | AI 结果填充到 textarea(覆盖手动编辑),可 [撤销] |
|
||||
| AI 返回后用户手动再编辑 → 失焦 | 手动编辑内容通过 blur 保存(不是 AI 内容) |
|
||||
@@ -51,10 +51,24 @@
|
||||
- [x] `@blur` → JSON.parse → `store.updateScene(id, parsed)`
|
||||
- [x] 解析失败 → 红色边框 + 错误提示
|
||||
|
||||
## E17: AI 编码助手 — opencode + DeepSeek 集成
|
||||
|
||||
目标:编辑器内嵌 AI 对话面板。JSON 模式填充 textarea 供用户审查后接受;代码模式直接修改 Vue 组件/CSS,Vite HMR 实时预览。
|
||||
|
||||
详见 [E17_AI_ASSISTANT_PROPOSAL.md](../E17_AI_ASSISTANT_PROPOSAL.md)
|
||||
|
||||
- [x] `vite.config.ts` — `POST /api/ai` 中间件(spawn opencode)
|
||||
- [x] `editor/composables/useAI.ts` — DeepSeek API 调用封装
|
||||
- [x] `editor/components/AIPanel.vue` — AI 对话面板
|
||||
- [x] `editor/components/NodeEditor.vue` — `aiResult` prop + [接受]/[撤销]
|
||||
- [x] `editor/stores/editorStore.ts` — `deepseekKey` + `showAIPanel` + `aiResult`
|
||||
- [x] `editor/App.vue` — "AI 助手" 按钮 + API Key 设置
|
||||
|
||||
**优先级建议:**
|
||||
|
||||
| 优先级 | 编号 | 说明 |
|
||||
|:--:|------|------|
|
||||
| **P0** | E17 | AI 编码助手 — DeepSeek 集成 ✅ 核心已完成 |
|
||||
| **P0** | E16 | JSON 编辑器 ✅ 已完成 |
|
||||
| **P0** | E10 | 内嵌快速测试 |
|
||||
| **P1** | E12 | JSON 校验 |
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEditorStore } from './stores/editorStore'
|
||||
import SceneGraph from './components/SceneGraph.vue'
|
||||
import NodeEditor from './components/NodeEditor.vue'
|
||||
import PreviewPanel from './components/PreviewPanel.vue'
|
||||
import AIPanel from './components/AIPanel.vue'
|
||||
|
||||
const store = useEditorStore()
|
||||
const editor = useGraphEditor()
|
||||
@@ -111,6 +112,9 @@ onMounted(() => restoreOrLoad())
|
||||
<button @click="showPreview = !showPreview" :class="{ secondary: true, active: showPreview }">
|
||||
{{ showPreview ? '📐 图谱' : '🎬 预览' }}
|
||||
</button>
|
||||
<button @click="store.showAIPanel = !store.showAIPanel" :class="{ secondary: true, active: store.showAIPanel }">
|
||||
🤖 AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +143,7 @@ onMounted(() => restoreOrLoad())
|
||||
@delete-scene="delNode"
|
||||
@close="store.selectedNodeId = null"
|
||||
/>
|
||||
<AIPanel v-if="store.showAIPanel" />
|
||||
</div>
|
||||
|
||||
<input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
|
||||
|
||||
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;
|
||||
|
||||
18
editor/composables/useAI.ts
Normal file
18
editor/composables/useAI.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export async function sendAIRequest(sessionId: string, userMessage: string, mode: string, apiKey: string): Promise<{ result: string }> {
|
||||
const resp = await fetch('/api/ai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, userMessage, apiKey, mode }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ error: 'request failed' }))
|
||||
throw new Error(err.error || 'request failed')
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<any[]> {
|
||||
const resp = await fetch('/api/ai/sessions')
|
||||
if (!resp.ok) return []
|
||||
return resp.json()
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const startSceneId = ref('')
|
||||
const dirty = ref(false)
|
||||
const sourcePath = ref('/scenes/demo.json')
|
||||
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
|
||||
const showAIPanel = ref(false)
|
||||
const aiResult = ref('')
|
||||
const aiSessionId = ref(localStorage.getItem('editor_ai_session') || '')
|
||||
|
||||
const selectedScene = computed(() => {
|
||||
if (!selectedNodeId.value) return null
|
||||
@@ -132,6 +136,22 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
function setSourcePath(p: string) { sourcePath.value = p; localStorage.setItem('editor_last_source', p) }
|
||||
|
||||
function setDeepseekKey(k: string) { deepseekKey.value = k; localStorage.setItem('deepseek_key', k) }
|
||||
|
||||
function ensureAISession() {
|
||||
if (!aiSessionId.value) {
|
||||
aiSessionId.value = crypto.randomUUID()
|
||||
localStorage.setItem('editor_ai_session', aiSessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
function newAISession() {
|
||||
aiSessionId.value = crypto.randomUUID()
|
||||
localStorage.setItem('editor_ai_session', aiSessionId.value)
|
||||
}
|
||||
|
||||
function setAIResult(r: string) { aiResult.value = r }
|
||||
|
||||
async function autoSave() {
|
||||
try {
|
||||
await fetch('/api/save', {
|
||||
@@ -144,8 +164,9 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
|
||||
return {
|
||||
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
|
||||
deepseekKey, showAIPanel, aiResult, aiSessionId,
|
||||
markDirty, loadJSON, exportJSON, addScene, deleteScene,
|
||||
updateScene, addChoice, updateChoice, deleteChoice, generateId,
|
||||
setSourcePath, autoSave,
|
||||
setSourcePath, setDeepseekKey, ensureAISession, newAISession, setAIResult, autoSave,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"opencode-ai": "^1.17.6",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
|
||||
@@ -114,7 +114,7 @@ function handleStartFromScene(sceneId: string) {
|
||||
if (sc.choices) for (const ch2 of sc.choices) if (ch2.targetScene) queue.push(ch2.targetScene)
|
||||
if (sc.nextScene) {
|
||||
if (Array.isArray(sc.nextScene)) for (const r of sc.nextScene) if (r.targetScene) queue.push(r.targetScene)
|
||||
else queue.push(sc.nextScene)
|
||||
else queue.push(sc.nextScene as unknown as string)
|
||||
}
|
||||
if (sc.qte) { if (sc.qte.successScene) queue.push(sc.qte.successScene); if (sc.qte.failScene) queue.push(sc.qte.failScene) }
|
||||
if (sc.hotspots) for (const h of sc.hotspots) if (h.targetScene) queue.push(h.targetScene)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import fs from 'fs'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
function apiSavePlugin() {
|
||||
return {
|
||||
@@ -29,6 +30,87 @@ function apiSavePlugin() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const dedupMap = new Map<string, number>()
|
||||
|
||||
server.middlewares.use('/api/ai', (req: any, res: any) => {
|
||||
if (req.method !== 'POST') { res.writeHead(405); res.end(); return }
|
||||
let body = ''
|
||||
req.on('data', (c: string) => body += c)
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { sessionId, userMessage, apiKey, mode } = JSON.parse(body)
|
||||
if (!userMessage || !sessionId) { res.writeHead(400); res.end(JSON.stringify({ error: 'missing fields' })); return }
|
||||
|
||||
const dedupKey = `${mode}_${userMessage}`
|
||||
const last = dedupMap.get(dedupKey) || 0
|
||||
if (Date.now() - last < 3000) {
|
||||
res.writeHead(429)
|
||||
res.end(JSON.stringify({ error: 'duplicate request' }))
|
||||
return
|
||||
}
|
||||
dedupMap.set(dedupKey, Date.now())
|
||||
|
||||
const modePrefix = mode === 'code'
|
||||
? '代码模式:直接修改 src/ 下的源码文件并保存。需求:'
|
||||
: 'JSON模式:只返回修改后的 JSON 文本,不要写任何文件。需求:'
|
||||
const fullMessage = modePrefix + userMessage
|
||||
|
||||
const opencodeBin = resolve(__dirname, 'node_modules', '.bin', 'opencode')
|
||||
const child = spawn(opencodeBin, ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage], {
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: apiKey || process.env.DEEPSEEK_API_KEY || '' },
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (d: Buffer) => stdout += d.toString())
|
||||
child.stderr.on('data', (d: Buffer) => stderr += d.toString())
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
res.writeHead(500)
|
||||
res.end(JSON.stringify({ error: 'opencode exited with code ' + code, stderr }))
|
||||
return
|
||||
}
|
||||
if (mode === 'json') {
|
||||
// try to extract JSON block from response
|
||||
const match = stdout.match(/```json\n?([\s\S]*?)\n?```|\{[\s\S]*\}/)
|
||||
const jsonStr = match ? (match[1] || match[0]) : stdout
|
||||
try { JSON.parse(jsonStr) } catch {
|
||||
res.writeHead(500)
|
||||
res.end(JSON.stringify({ error: 'invalid JSON returned', raw: stdout }))
|
||||
return
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ result: jsonStr }))
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ result: stdout || 'done' }))
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
res.writeHead(400)
|
||||
res.end(JSON.stringify({ error: e.message }))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
server.middlewares.use('/api/ai/sessions', (req: any, res: any) => {
|
||||
try {
|
||||
const opencodeBin = resolve(__dirname, 'node_modules', '.bin', 'opencode')
|
||||
const child = spawn(opencodeBin, ['session', 'list'], { timeout: 5000 })
|
||||
let stdout = ''
|
||||
child.stdout.on('data', (d: Buffer) => stdout += d.toString())
|
||||
child.on('close', () => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(stdout || '[]')
|
||||
})
|
||||
} catch {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end('[]')
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user