feat(ai): 升级 AI 对话界面并支持多模型选择

- 实现多 AI 模型选择功能
- 集成流式响应提升用户体验
- 重构样式增加动画效果
- 添加代码块高亮和复制功能
- 优化响应式布局和交互体验
- 更新 .gitignore 配置文件
This commit is contained in:
冰点
2026-04-15 11:56:55 +08:00
parent ccce1bdbc7
commit 8fdcec00fb
17 changed files with 2318 additions and 533 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ bin/
.myeclipse
node_modules/
dist/
.gitnexus
/.claude/skills

View File

@@ -2,19 +2,46 @@
<teleport to=".magic-editor">
<div v-if="visible" class="magic-ai-panel">
<div class="magic-ai-panel-header">
<span>AI Coding 助手</span>
<span class="magic-ai-close" @click="onClose"><magic-icon icon="close"/></span>
<div class="magic-ai-header-left">
<span class="magic-ai-header-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a4 4 0 0 1 4 4v1a3 3 0 0 1 3 3v1a2 2 0 0 1-2 2h-1l-2 5H10l-2-5H7a2 2 0 0 1-2-2v-1a3 3 0 0 1 3-3V6a4 4 0 0 1 4-4z"/>
<circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/>
</svg>
</span>
<span class="magic-ai-header-title">AI Coding 助手</span>
</div>
<span class="magic-ai-close" @click="onClose">
<magic-icon icon="close"/>
</span>
</div>
<div class="magic-ai-container">
<!-- 对话消息区域 -->
<div class="magic-ai-messages" ref="messagesContainer">
<div v-if="messages.length === 0" class="magic-ai-welcome">
<div class="magic-ai-welcome-icon"></div>
<div class="magic-ai-welcome-icon-wrap">
<svg class="magic-ai-welcome-svg" viewBox="0 0 64 64" width="52" height="52" fill="none">
<defs>
<linearGradient id="aiGrad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#5b7ee5"/>
<stop offset="100%" stop-color="#4da6e8"/>
</linearGradient>
</defs>
<rect rx="16" width="64" height="64" fill="url(#aiGrad)" opacity="0.12"/>
<path d="M32 14a8 8 0 0 1 8 8v2a6 6 0 0 1 6 6v2a4 4 0 0 1-4 4h-2l-4 10h-8l-4-10h-2a4 4 0 0 1-4-4v-2a6 6 0 0 1 6-6v-2a8 8 0 0 1 8-8z" stroke="url(#aiGrad)" stroke-width="2.5" fill="none"/>
<circle cx="26" cy="26" r="2" fill="#5b7ee5"/>
<circle cx="38" cy="26" r="2" fill="#4da6e8"/>
<path d="M28 31c1.5 2 6.5 2 8 0" stroke="url(#aiGrad)" stroke-width="2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="magic-ai-welcome-title">AI Coding 助手</div>
<div class="magic-ai-welcome-desc">我可以帮你按照 magic-api 脚本语法生成代码请描述你的需求</div>
<div class="magic-ai-suggestions">
<div class="magic-ai-suggestion" v-for="s in suggestions" :key="s" @click="sendSuggestion(s)">
{{ s }}
<span class="magic-ai-suggestion-icon">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M6 3l5 5-5 5"/></svg>
</span>
<span>{{ s }}</span>
</div>
</div>
</div>
@@ -25,32 +52,48 @@
:class="['magic-ai-message', msg.role === 'user' ? 'magic-ai-message--user' : 'magic-ai-message--assistant']"
>
<div class="magic-ai-message-avatar">
<span v-if="msg.role === 'user'"></span>
<span v-else>AI</span>
<span v-if="msg.role === 'user'">
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor"><path d="M10 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm-7 8a7 7 0 0 1 14 0H3z"/></svg>
</span>
<span v-else>
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3a3.5 3.5 0 0 1 3.5 3.5V8A2.5 2.5 0 0 1 16 10.5V12a2 2 0 0 1-2 2h-.5L12 17H8l-1.5-3H6a2 2 0 0 1-2-2v-1.5A2.5 2.5 0 0 1 6.5 8v-1.5A3.5 3.5 0 0 1 10 3z"/><circle cx="8" cy="9" r="0.8" fill="currentColor"/><circle cx="12" cy="9" r="0.8" fill="currentColor"/></svg>
</span>
</div>
<div class="magic-ai-message-content">
<div v-if="msg.role === 'assistant'" v-html="renderMarkdown(msg.content)"></div>
<div v-else>{{ msg.content }}</div>
<div class="magic-ai-message-body">
<div class="magic-ai-message-content">
<div v-if="msg.role === 'assistant'" v-html="renderMarkdown(msg.content)"></div>
<div v-else>{{ msg.content }}</div>
<span v-if="streaming && msg.role === 'assistant' && index === messages.length - 1" class="magic-ai-cursor"></span>
</div>
<!-- 代码操作按钮 -->
<div class="magic-ai-code-actions" v-if="msg.role === 'assistant' && extractCode(msg.content)">
<div class="magic-ai-code-actions" v-if="msg.role === 'assistant' && !streaming && extractCode(msg.content)">
<button class="magic-ai-btn magic-ai-btn--insert" @click="insertCode(extractCode(msg.content))">
插入到编辑器
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
插入代码
</button>
<button class="magic-ai-btn magic-ai-btn--replace" @click="replaceCode(extractCode(msg.content))">
替换编辑器内容
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 8h12M12 4l4 4-4 4"/></svg>
替换代码
</button>
<button class="magic-ai-btn magic-ai-btn--copy" @click="copyCode(extractCode(msg.content))">
复制代码
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5v-7A1.5 1.5 0 0 1 3.5 1h7A1.5 1.5 0 0 1 12 2.5V5"/></svg>
复制
</button>
</div>
</div>
</div>
<!-- 正在生成 -->
<div v-if="loading" class="magic-ai-message magic-ai-message--assistant">
<div class="magic-ai-message-avatar"><span>AI</span></div>
<div class="magic-ai-message-content">
<div class="magic-ai-loading">
<span></span><span></span><span></span>
<!-- 正在等待非流式阶段才显示loading动画 -->
<div v-if="loading && !streaming" class="magic-ai-message magic-ai-message--assistant">
<div class="magic-ai-message-avatar">
<span>
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3a3.5 3.5 0 0 1 3.5 3.5V8A2.5 2.5 0 0 1 16 10.5V12a2 2 0 0 1-2 2h-.5L12 17H8l-1.5-3H6a2 2 0 0 1-2-2v-1.5A2.5 2.5 0 0 1 6.5 8v-1.5A3.5 3.5 0 0 1 10 3z"/><circle cx="8" cy="9" r="0.8" fill="currentColor"/><circle cx="12" cy="9" r="0.8" fill="currentColor"/></svg>
</span>
</div>
<div class="magic-ai-message-body">
<div class="magic-ai-message-content">
<div class="magic-ai-loading">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
@@ -59,13 +102,29 @@
<!-- 输入区域 -->
<div class="magic-ai-input-area">
<!-- 模型选择器 -->
<div class="magic-ai-model-selector" v-if="configLoaded && selectableProviders.length > 0">
<div class="magic-ai-select-wrap">
<select class="magic-ai-select" :value="activeProvider" @change="onProviderChange">
<option v-for="p in selectableProviders" :key="p.key" :value="p.key">{{ p.label }}</option>
</select>
<svg class="magic-ai-select-arrow" viewBox="0 0 12 12" width="10" height="10"><path d="M3 5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="magic-ai-select-wrap magic-ai-select-wrap--model">
<select class="magic-ai-select" :value="activeModel" @change="onModelChange">
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
</select>
<svg class="magic-ai-select-arrow" viewBox="0 0 12 12" width="10" height="10"><path d="M3 5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="magic-ai-input-toolbar">
<label class="magic-ai-context-toggle">
<input type="checkbox" v-model="useCurrentCode" />
携带当前代码作为上下文
<span class="magic-ai-toggle-text">携带当前代码上下文</span>
</label>
<button class="magic-ai-clear-btn" @click="clearMessages" :disabled="messages.length === 0">
清空对话
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 4h12M5 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1M6 7v5M10 7v5M4 4l1 10h6l1-10"/></svg>
清空
</button>
</div>
<div class="magic-ai-input-wrapper">
@@ -73,12 +132,15 @@
ref="inputRef"
v-model="inputText"
class="magic-ai-textarea"
placeholder="描述你需要实现的功能,按 Enter 发送Shift+Enter 换行..."
placeholder="描述你需要实现的功能... Enter 发送Shift+Enter 换行"
@keydown="onKeydown"
:disabled="loading"
rows="3"
></textarea>
<button class="magic-ai-send-btn" @click="sendMessage" :disabled="loading || !inputText.trim()">
<magic-icon icon="run" />
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 10l6-6 6 6M10 4v12"/>
</svg>
</button>
</div>
</div>
@@ -88,7 +150,7 @@
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ref, nextTick, computed } from 'vue'
const props = defineProps({
bus: Object,
@@ -100,21 +162,110 @@ const visible = ref(false)
const messages = ref([])
const inputText = ref('')
const loading = ref(false)
const streaming = ref(false) // 流式输出中
const useCurrentCode = ref(false)
const messagesContainer = ref(null)
const inputRef = ref(null)
// 模型选择相关状态
const activeProvider = ref('')
const activeModel = ref('')
const configuredProviders = ref([]) // [{key, label, models}]
const configLoaded = ref(false)
const STORAGE_KEY_PROVIDER = 'magic-ai-provider'
const STORAGE_KEY_MODEL = 'magic-ai-model'
// 从 localStorage 读取上次选择
const loadLocalSelection = () => {
try {
const p = localStorage.getItem(STORAGE_KEY_PROVIDER)
const m = localStorage.getItem(STORAGE_KEY_MODEL)
if (p) activeProvider.value = p
if (m) activeModel.value = m
} catch (e) { /* ignore */ }
}
// 保存选择到 localStorage
const saveLocalSelection = () => {
try {
localStorage.setItem(STORAGE_KEY_PROVIDER, activeProvider.value)
localStorage.setItem(STORAGE_KEY_MODEL, activeModel.value)
} catch (e) { /* ignore */ }
}
// 过滤出后端已配置的提供商
const selectableProviders = computed(() => {
return configuredProviders.value.map(p => ({ key: p.key, label: p.label || p.key }))
})
// 当前提供商下的可用模型
const availableModels = computed(() => {
const provider = activeProvider.value
const found = configuredProviders.value.find(p => p.key === provider)
return found && found.models ? found.models : []
})
// 从后端加载已配置的提供商列表,并恢复本地选择
const loadAiConfig = () => {
props.request.sendJson('/ai/config/get', {}).success(data => {
if (Array.isArray(data)) {
configuredProviders.value = data
}
// 恢复 localStorage 中的选择
loadLocalSelection()
// 如果本地没有选择或选择的提供商已不存在,使用第一个提供商
const providerKeys = configuredProviders.value.map(p => p.key)
if (!activeProvider.value || !providerKeys.includes(activeProvider.value)) {
if (configuredProviders.value.length > 0) {
activeProvider.value = configuredProviders.value[0].key
const models = configuredProviders.value[0].models || []
activeModel.value = models[0] || ''
saveLocalSelection()
}
} else {
// 验证当前模型是否仍在可用列表中
const found = configuredProviders.value.find(p => p.key === activeProvider.value)
const models = found && found.models ? found.models : []
if (activeModel.value && !models.includes(activeModel.value)) {
activeModel.value = models[0] || ''
saveLocalSelection()
}
}
configLoaded.value = true
}).error(() => {
configLoaded.value = true
})
}
const onProviderChange = (e) => {
const newProvider = e.target.value
activeProvider.value = newProvider
// 自动选择新提供商的第一个模型
const found = configuredProviders.value.find(p => p.key === newProvider)
const models = found && found.models ? found.models : []
activeModel.value = models[0] || ''
saveLocalSelection()
}
const onModelChange = (e) => {
activeModel.value = e.target.value
saveLocalSelection()
}
const suggestions = [
'查询用户列表,支持按名称模糊搜索和分页',
'根据ID查询用户详情并返回',
'新增用户接收POST请求体参数',
'更新用户信息根据ID修改',
'删除用户根据ID删除',
]
// 从bus事件打开对话框
props.bus.$on('ai-dialog-open', () => {
visible.value = true
if (!configLoaded.value) {
loadAiConfig()
}
nextTick(() => inputRef.value && inputRef.value.focus())
})
@@ -127,6 +278,14 @@ const sendSuggestion = (text) => {
sendMessage()
}
// 获取请求的 baseURL从当前页面路径推导
const getBaseURL = () => {
const path = window.location.pathname
// 页面路径如 /magic/web/index.html 或 /magic/web/,取目录部分
const dir = path.endsWith('/') ? path.slice(0, -1) : path.substring(0, path.lastIndexOf('/'))
return window.location.origin + dir
}
const sendMessage = async () => {
const text = inputText.value.trim()
if (!text || loading.value) return
@@ -142,6 +301,7 @@ const sendMessage = async () => {
messages.value.push({ role: 'user', content: text })
inputText.value = ''
loading.value = true
streaming.value = false
scrollToBottom()
const history = messages.value.slice(0, -1).slice(-10).map(m => ({
@@ -149,19 +309,88 @@ const sendMessage = async () => {
content: m.content
}))
props.request.sendJson('/ai/chat', {
message: text,
history,
currentCode: useCurrentCode.value ? currentCode : ''
}).success(response => {
messages.value.push({ role: 'assistant', content: response.message })
// 添加空的 assistant 消息占位,用于流式追加
messages.value.push({ role: 'assistant', content: '' })
const msgIndex = messages.value.length - 1
scrollToBottom()
const token = localStorage.getItem('magic-token') || 'unauthorization'
const baseURL = getBaseURL()
try {
const resp = await fetch(baseURL + '/ai/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'magic-token': token
},
body: JSON.stringify({
message: text,
history,
currentCode: useCurrentCode.value ? currentCode : '',
provider: activeProvider.value,
model: activeModel.value
})
})
if (!resp.ok) {
messages.value[msgIndex].content = '请求失败,状态码: ' + resp.status
scrollToBottom()
return
}
streaming.value = true
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 按行解析 SSE 数据
const lines = buffer.split('\n')
buffer = lines.pop() // 保留不完整的最后一行
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(':')) continue
if (trimmed.startsWith('event:done') || trimmed.startsWith('event: done')) {
// 流结束
continue
}
if (trimmed.startsWith('data:')) {
const data = trimmed.substring(5)
if (data.trim() === '[DONE]') continue
// SSE data 是 JSON 编码的字符串parse 还原换行等特殊字符
try {
const text = JSON.parse(data)
messages.value[msgIndex].content += text
} catch (e) {
// 解析失败则直接追加原始文本
messages.value[msgIndex].content += data
}
scrollToBottom()
}
}
}
// 如果最终内容为空,显示提示
if (!messages.value[msgIndex].content.trim()) {
messages.value[msgIndex].content = 'AI返回了空内容请检查配置。'
}
} catch (e) {
if (!messages.value[msgIndex].content) {
messages.value[msgIndex].content = '请求失败请检查AI配置是否正确。'
} else {
messages.value[msgIndex].content += '\n\n连接中断'
}
scrollToBottom()
}).error(() => {
messages.value.push({ role: 'assistant', content: '❌ 请求失败请检查AI配置是否正确。' })
scrollToBottom()
}).end(() => {
} finally {
loading.value = false
})
streaming.value = false
}
}
const onKeydown = (e) => {
@@ -242,101 +471,183 @@ defineExpose({ visible })
top: var(--magic-header-height);
right: 0;
bottom: 0;
width: 380px;
width: 420px;
display: flex;
flex-direction: column;
background: var(--main-background-color);
border-left: 1px solid var(--main-border-color);
z-index: 100;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Header */
.magic-ai-panel-header {
height: var(--magic-header-height);
line-height: var(--magic-header-height);
padding: 0 10px;
height: 44px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--main-border-color);
font-size: 13px;
font-weight: bold;
color: var(--main-color);
background: var(--main-background-color);
flex-shrink: 0;
}
.magic-ai-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.magic-ai-header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #5b7ee5 0%, #4da6e8 100%);
color: #fff;
}
.magic-ai-header-title {
font-size: 13px;
font-weight: 600;
color: var(--main-color);
letter-spacing: 0.3px;
}
.magic-ai-close {
cursor: pointer;
display: flex;
align-items: center;
opacity: 0.6;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
opacity: 0.5;
transition: all 0.15s;
}
.magic-ai-close:hover {
opacity: 1;
background: var(--select-option-hover-background-color);
}
.magic-ai-close:hover { opacity: 1; }
.magic-ai-close :deep(.magic-icon) {
width: 14px;
height: 14px;
}
/* Container */
.magic-ai-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--main-background-color);
}
/* Messages area */
.magic-ai-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 16px;
min-height: 0;
}
.magic-ai-messages::-webkit-scrollbar {
width: 5px;
}
.magic-ai-messages::-webkit-scrollbar-thumb {
background: var(--main-border-color);
border-radius: 3px;
}
.magic-ai-messages::-webkit-scrollbar-track {
background: transparent;
}
/* Welcome */
.magic-ai-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--main-color);
opacity: 0.8;
padding: 20px;
}
.magic-ai-welcome-icon-wrap {
margin-bottom: 16px;
}
.magic-ai-welcome-title {
font-size: 17px;
font-weight: 600;
color: var(--main-color);
margin-bottom: 6px;
}
.magic-ai-welcome-icon { font-size: 48px; margin-bottom: 10px; }
.magic-ai-welcome-title { font-size: 18px; font-weight: bold; margin-bottom: 8px; }
.magic-ai-welcome-desc {
font-size: 13px;
font-size: 12px;
color: var(--empty-color);
margin-bottom: 20px;
margin-bottom: 24px;
text-align: center;
line-height: 1.5;
max-width: 280px;
}
.magic-ai-suggestions {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
width: 100%;
max-width: 480px;
max-width: 360px;
}
.magic-ai-suggestion {
padding: 8px 14px;
padding: 10px 14px;
border: 1px solid var(--main-border-color);
border-radius: 6px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
color: var(--main-color);
transition: background 0.15s;
transition: all 0.15s ease;
text-align: left;
display: flex;
align-items: center;
gap: 8px;
line-height: 1.4;
}
.magic-ai-suggestion-icon {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.4;
transition: all 0.15s;
}
.magic-ai-suggestion:hover {
background: var(--select-option-hover-background-color);
color: var(--select-option-hover-color);
border-color: #5b7ee5;
transform: translateX(2px);
}
.magic-ai-suggestion:hover .magic-ai-suggestion-icon {
opacity: 0.8;
color: #5b7ee5;
}
/* Messages */
.magic-ai-message {
display: flex;
gap: 10px;
align-items: flex-start;
animation: fadeInMsg 0.2s ease-out;
}
@keyframes fadeInMsg {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.magic-ai-message--user {
flex-direction: row-reverse;
}
.magic-ai-message--user { flex-direction: row-reverse; }
.magic-ai-message-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
@@ -344,24 +655,37 @@ defineExpose({ visible })
font-weight: bold;
flex-shrink: 0;
}
.magic-ai-message--user .magic-ai-message-avatar { background: #1976d2; color: #fff; }
.magic-ai-message--assistant .magic-ai-message-avatar { background: #43a047; color: #fff; }
.magic-ai-message--user .magic-ai-message-avatar {
background: #5b7ee5;
color: #fff;
}
.magic-ai-message--assistant .magic-ai-message-avatar {
background: linear-gradient(135deg, #5b7ee5 0%, #4da6e8 100%);
color: #fff;
}
.magic-ai-message-body {
max-width: 82%;
min-width: 0;
}
.magic-ai-message-content {
max-width: 85%;
padding: 8px 12px;
border-radius: 8px;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
line-height: 1.6;
line-height: 1.65;
color: var(--main-color);
background: var(--select-option-hover-background-color);
word-break: break-word;
}
.magic-ai-message--user .magic-ai-message-content {
background: #1565c0;
color: #fff;
border-radius: 8px 2px 8px 8px;
background: var(--select-option-hover-background-color);
color: var(--main-color);
border-radius: 12px 4px 12px 12px;
}
.magic-ai-message--assistant .magic-ai-message-content { border-radius: 2px 8px 8px 8px; }
.magic-ai-message--assistant .magic-ai-message-content {
border-radius: 4px 12px 12px 12px;
}
/* Code action buttons */
.magic-ai-code-actions {
display: flex;
gap: 6px;
@@ -369,74 +693,162 @@ defineExpose({ visible })
flex-wrap: wrap;
}
.magic-ai-btn {
padding: 3px 10px;
border-radius: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
border: 1px solid;
border: none;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 4px;
font-family: inherit;
}
.magic-ai-btn--insert { background: #43a047; color: #fff; border-color: #43a047; }
.magic-ai-btn--insert:hover { background: #388e3c; }
.magic-ai-btn--replace { background: #1976d2; color: #fff; border-color: #1976d2; }
.magic-ai-btn--replace:hover { background: #1565c0; }
.magic-ai-btn--copy { background: transparent; color: var(--main-color); border-color: var(--main-border-color); }
.magic-ai-btn--copy:hover { background: var(--select-option-hover-background-color); }
.magic-ai-btn--insert {
background: var(--select-option-hover-background-color);
color: var(--main-color);
border: 1px solid var(--main-border-color);
}
.magic-ai-btn--insert:hover {
background: #5b7ee5;
color: #fff;
border-color: #5b7ee5;
}
.magic-ai-btn--replace {
background: var(--select-option-hover-background-color);
color: var(--main-color);
border: 1px solid var(--main-border-color);
}
.magic-ai-btn--replace:hover {
background: #5b7ee5;
color: #fff;
border-color: #5b7ee5;
}
.magic-ai-btn--copy {
background: transparent;
color: var(--main-color);
border: 1px solid var(--main-border-color);
}
.magic-ai-btn--copy:hover {
background: var(--select-option-hover-background-color);
}
/* Loading dots */
.magic-ai-loading {
display: flex;
gap: 4px;
gap: 5px;
align-items: center;
padding: 4px 0;
}
.magic-ai-loading span {
width: 7px;
height: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--main-icon-color);
animation: aiDot 1.2s infinite;
background: var(--main-icon-color, #999);
animation: aiDot 1.4s infinite ease-in-out;
}
.magic-ai-loading span:nth-child(2) { animation-delay: 0.2s; }
.magic-ai-loading span:nth-child(3) { animation-delay: 0.4s; }
@keyframes aiDot {
0%, 60%, 100% { transform: scale(0.6); opacity: 0.4; }
30% { transform: scale(1); opacity: 1; }
0%, 80%, 100% { transform: scale(0.5); opacity: 0.3; }
40% { transform: scale(1); opacity: 1; }
}
/* Input area */
.magic-ai-input-area {
border-top: 1px solid var(--main-border-color);
padding: 8px 12px 10px;
padding: 10px 14px 12px;
background: var(--main-background-color);
flex-shrink: 0;
}
/* Model selector */
.magic-ai-model-selector {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.magic-ai-select-wrap {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.magic-ai-select-wrap--model {
flex: 1.5;
}
.magic-ai-select {
width: 100%;
height: 30px;
padding: 0 22px 0 8px;
border: 1px solid var(--main-border-color);
border-radius: 6px;
background: var(--main-background-color);
color: var(--main-color);
font-size: 12px;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
transition: border-color 0.15s;
}
.magic-ai-select:focus {
border-color: #5b7ee5;
}
.magic-ai-select-arrow {
position: absolute;
right: 8px;
pointer-events: none;
opacity: 0.5;
color: var(--main-color);
}
/* Toolbar */
.magic-ai-input-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
font-size: 12px;
color: var(--empty-color);
margin-bottom: 8px;
}
.magic-ai-context-toggle {
display: flex;
align-items: center;
gap: 4px;
gap: 5px;
cursor: pointer;
user-select: none;
}
.magic-ai-context-toggle input { cursor: pointer; accent-color: #1976d2; }
.magic-ai-context-toggle input {
cursor: pointer;
accent-color: #5b7ee5;
width: 14px;
height: 14px;
}
.magic-ai-toggle-text {
font-size: 11px;
color: var(--empty-color);
}
.magic-ai-clear-btn {
padding: 2px 8px;
padding: 3px 8px;
background: transparent;
border: 1px solid var(--main-border-color);
border-radius: 3px;
border-radius: 5px;
font-size: 11px;
cursor: pointer;
color: var(--empty-color);
display: flex;
align-items: center;
gap: 3px;
transition: all 0.15s;
font-family: inherit;
}
.magic-ai-clear-btn:hover:not(:disabled) {
background: var(--select-option-hover-background-color);
color: var(--main-color);
border-color: var(--main-color);
}
.magic-ai-clear-btn:disabled { opacity: 0.4; cursor: default; }
.magic-ai-clear-btn:disabled { opacity: 0.3; cursor: default; }
/* Input wrapper */
.magic-ai-input-wrapper {
display: flex;
gap: 8px;
@@ -444,11 +856,11 @@ defineExpose({ visible })
}
.magic-ai-textarea {
flex: 1;
min-height: 60px;
min-height: 56px;
max-height: 120px;
padding: 8px 10px;
padding: 8px 12px;
border: 1px solid var(--main-border-color);
border-radius: 6px;
border-radius: 10px;
resize: none;
font-size: 13px;
background: var(--main-background-color);
@@ -456,70 +868,111 @@ defineExpose({ visible })
font-family: inherit;
line-height: 1.5;
outline: none;
transition: border-color 0.15s;
transition: border-color 0.2s, box-shadow 0.2s;
}
.magic-ai-textarea:focus { border-color: #1976d2; }
.magic-ai-textarea:disabled { opacity: 0.6; cursor: not-allowed; }
.magic-ai-textarea:focus {
border-color: #5b7ee5;
box-shadow: 0 0 0 2px rgba(91, 126, 229, 0.12);
}
.magic-ai-textarea:disabled { opacity: 0.5; cursor: not-allowed; }
.magic-ai-textarea::placeholder { color: var(--empty-color); font-size: 12px; }
/* Send button */
.magic-ai-send-btn {
width: 38px;
height: 38px;
border-radius: 6px;
background: #43a047;
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, #5b7ee5 0%, #4da6e8 100%);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s;
transition: all 0.2s;
color: #fff;
}
.magic-ai-send-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(91, 126, 229, 0.3);
}
.magic-ai-send-btn:active:not(:disabled) {
transform: translateY(0);
}
.magic-ai-send-btn:hover:not(:disabled) { background: #388e3c; }
.magic-ai-send-btn:disabled {
background: var(--main-border-color);
cursor: not-allowed;
opacity: 0.6;
opacity: 0.5;
}
/* Streaming cursor */
.magic-ai-cursor {
display: inline;
animation: blink 1s step-end infinite;
color: var(--main-color);
font-weight: bold;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.magic-ai-send-btn :deep(.magic-icon) { fill: #fff; }
</style>
<style>
/* Code block global styles */
.magic-ai-message-content .magic-ai-code-block {
background: #1e1e1e;
border-radius: 6px;
background: #1a1b26;
border-radius: 8px;
margin: 8px 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.magic-ai-message-content .magic-ai-code-block pre {
margin: 0;
padding: 12px;
padding: 14px 16px;
overflow-x: auto;
}
.magic-ai-message-content .magic-ai-code-block pre::-webkit-scrollbar {
height: 4px;
}
.magic-ai-message-content .magic-ai-code-block pre::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.magic-ai-message-content .magic-ai-code-block code {
color: #d4d4d4;
font-family: 'JetBrainsMono', Consolas, 'Courier New', monospace;
color: #c0caf5;
font-family: 'JetBrainsMono', 'Fira Code', Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre;
}
.magic-ai-message-content .magic-ai-inline-code {
background: rgba(0, 0, 0, 0.15);
padding: 1px 5px;
border-radius: 3px;
font-family: monospace;
background: var(--select-option-hover-background-color);
padding: 1px 6px;
border-radius: 4px;
font-family: 'JetBrainsMono', Consolas, monospace;
font-size: 12px;
}
.magic-ai-message--user .magic-ai-message-content .magic-ai-inline-code {
background: rgba(255, 255, 255, 0.2);
color: var(--main-color);
}
.magic-ai-message-content h2,
.magic-ai-message-content h3,
.magic-ai-message-content h4 {
margin: 8px 0 4px;
font-weight: bold;
margin: 10px 0 4px;
font-weight: 600;
line-height: 1.4;
}
.magic-ai-message-content h2 { font-size: 15px; }
.magic-ai-message-content h3 { font-size: 14px; }
.magic-ai-message-content h4 { font-size: 13px; }
.magic-ai-message-content ul {
margin: 4px 0;
padding-left: 20px;
padding-left: 18px;
}
.magic-ai-message-content li {
margin: 3px 0;
line-height: 1.5;
}
.magic-ai-message-content strong {
font-weight: 600;
}
.magic-ai-message-content li { margin: 2px 0; }
</style>

View File

@@ -15,6 +15,9 @@ export default {
copy: 'Copy Code',
copySuccess: 'Code copied to clipboard',
user: 'Me',
assistant: 'AI'
assistant: 'AI',
provider: 'Provider',
model: 'Model',
switchFailed: 'Failed to switch model, please check configuration'
}
}

View File

@@ -15,6 +15,9 @@ export default {
copy: '复制代码',
copySuccess: '代码已复制到剪贴板',
user: '我',
assistant: 'AI'
assistant: 'AI',
provider: '提供商',
model: '模型',
switchFailed: '切换模型失败请检查配置'
}
}

View File

@@ -1,10 +1,10 @@
<svg class="icon" style="width: 1em; height: 1em; vertical-align: middle; fill: currentColor; overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#83D3E7"/>
<stop offset="50%" stop-color="#6950BE"/>
<stop offset="100%" stop-color="#C53B77"/>
</linearGradient>
</defs>
<path d="M512 128C300.32 128 128 292.32 128 496C128 619.36 190.88 729.44 287.84 793.28L319.36 757.28C239.04 702.56 192 606.24 192 496C192 327.52 331.04 192 512 192C692.96 192 832 327.52 832 496C832 606.24 784.96 702.56 704.64 757.28L736.16 793.28C833.12 729.44 896 619.36 896 496C896 292.32 723.68 128 512 128ZM512 256C366.24 256 256 362.08 256 496C256 586.4 299.68 666.08 370.4 715.36L401.92 679.36C348.64 640.16 320 570.72 320 496C320 397.92 398.24 320 512 320C625.76 320 704 397.92 704 496C704 570.72 675.36 640.16 622.08 679.36L653.6 715.36C724.32 666.08 768 586.4 768 496C768 362.08 657.76 256 512 256ZM512 384C432.64 384 384 436.64 384 496C384 539.36 403.36 580.32 442.24 605.28L473.76 569.28C446.56 551.2 448 523.04 448 496C448 469.28 473.76 448 512 448C550.24 448 576 469.28 576 496C576 523.04 577.44 551.2 550.24 569.28L581.76 605.28C620.64 580.32 640 539.36 640 496C640 436.64 591.36 384 512 384Z" fill="url(#logoGradient)"/>
</svg>
<svg width="50" height="50" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#83D3E7"/>
<stop offset="50%" stop-color="#6950BE"/>
<stop offset="100%" stop-color="#C53B77"/>
</linearGradient>
</defs>
<path d="M512 128C300.32 128 128 292.32 128 496C128 619.36 190.88 729.44 287.84 793.28L319.36 757.28C239.04 702.56 192 606.24 192 496C192 327.52 331.04 192 512 192C692.96 192 832 327.52 832 496C832 606.24 784.96 702.56 704.64 757.28L736.16 793.28C833.12 729.44 896 619.36 896 496C896 292.32 723.68 128 512 128ZM512 256C366.24 256 256 362.08 256 496C256 586.4 299.68 666.08 370.4 715.36L401.92 679.36C348.64 640.16 320 570.72 320 496C320 397.92 398.24 320 512 320C625.76 320 704 397.92 704 496C704 570.72 675.36 640.16 622.08 679.36L653.6 715.36C724.32 666.08 768 586.4 768 496C768 362.08 657.76 256 512 256ZM512 384C432.64 384 384 436.64 384 496C384 539.36 403.36 580.32 442.24 605.28L473.76 569.28C446.56 551.2 448 523.04 448 496C448 469.28 473.76 448 512 448C550.24 448 576 469.28 576 496C576 523.04 577.44 551.2 550.24 569.28L581.76 605.28C620.64 580.32 640 539.36 640 496C640 436.64 591.36 384 512 384Z" fill="url(#logoGradient)"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -6,7 +6,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.ssssssss.magicapi.ai.service.*;
import org.ssssssss.magicapi.ai.service.AiServiceManager;
import org.ssssssss.magicapi.ai.web.MagicAiController;
import org.ssssssss.magicapi.core.config.MagicConfiguration;
import org.ssssssss.magicapi.core.config.MagicPluginConfiguration;
@@ -27,38 +27,17 @@ public class MagicAiConfiguration implements MagicPluginConfiguration {
public MagicAiConfiguration(MagicAiProperties properties) {
this.properties = properties;
logger.info("Magic AI Coding 模块已启用,提供商: {}", properties.getProvider());
logger.info("Magic AI Coding 模块已启用");
}
@Bean
public AiService aiService() {
String provider = properties.getProvider();
String apiUrl = properties.getApiUrl();
String apiKey = properties.getApiKey();
String model = properties.getModel();
logger.info("初始化AI服务提供商: {}, 模型: {}", provider, model);
switch (provider.toLowerCase()) {
case "dashscope":
case "aliyun":
return new DashScopeService(apiUrl, apiKey, model);
case "zhipu":
case "glm":
return new ZhipuAiService(apiUrl, apiKey, model);
case "minimax":
return new MiniMaxService(apiUrl, apiKey, model);
case "deepseek":
return new DeepSeekService(apiUrl, apiKey, model);
case "openai":
default:
return new OpenAiService(apiUrl, apiKey, model);
}
public AiServiceManager aiServiceManager() {
return new AiServiceManager(properties);
}
@Bean
public MagicAiController magicAiController(MagicConfiguration configuration, AiService aiService) {
return new MagicAiController(configuration, aiService);
public MagicAiController magicAiController(MagicConfiguration configuration, AiServiceManager aiServiceManager) {
return new MagicAiController(configuration, aiServiceManager);
}
@Override
@@ -69,7 +48,7 @@ public class MagicAiConfiguration implements MagicPluginConfiguration {
@Override
public MagicControllerRegister controllerRegister() {
return (mapping, configuration) -> {
MagicAiController controller = magicAiController(configuration, aiService());
MagicAiController controller = magicAiController(configuration, aiServiceManager());
mapping.registerController(controller);
logger.info("AI Coding 控制器已注册");
};

View File

@@ -2,6 +2,12 @@ package org.ssssssss.magicapi.ai.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* AI配置属性
*/
@@ -14,7 +20,24 @@ public class MagicAiProperties {
private boolean enabled = false;
/**
* AI服务提供商类型openai, azure, custom
* 默认活跃提供商(无持久化选择时使用)
*/
private String activeProvider = "openai";
/**
* 默认活跃模型(无持久化选择时使用)
*/
private String activeModel = "";
/**
* 多提供商配置key为提供商名称如 deepseek、openai、dashscope、zhipu、minimax
*/
private Map<String, ProviderConfig> providers = new LinkedHashMap<>();
// --- 向下兼容:旧的单提供商字段 ---
/**
* AI服务提供商类型openai, deepseek, dashscope, zhipu, minimax
*/
private String provider = "openai";
@@ -33,6 +56,29 @@ public class MagicAiProperties {
*/
private String model = "gpt-3.5-turbo";
/**
* 获取有效的提供商配置。
* 如果配置了 providers map 则直接返回;
* 否则从旧的单提供商字段合成一个单条目 map实现向下兼容。
*/
public Map<String, ProviderConfig> getEffectiveProviders() {
if (!providers.isEmpty()) {
return providers;
}
if (provider != null && apiKey != null) {
ProviderConfig config = new ProviderConfig();
config.setApiUrl(apiUrl);
config.setApiKey(apiKey);
config.setModel(model);
Map<String, ProviderConfig> map = new LinkedHashMap<>();
map.put(provider.toLowerCase(), config);
return map;
}
return Collections.emptyMap();
}
// --- Getters / Setters ---
public boolean isEnabled() {
return enabled;
}
@@ -41,6 +87,30 @@ public class MagicAiProperties {
this.enabled = enabled;
}
public String getActiveProvider() {
return activeProvider;
}
public void setActiveProvider(String activeProvider) {
this.activeProvider = activeProvider;
}
public String getActiveModel() {
return activeModel;
}
public void setActiveModel(String activeModel) {
this.activeModel = activeModel;
}
public Map<String, ProviderConfig> getProviders() {
return providers;
}
public void setProviders(Map<String, ProviderConfig> providers) {
this.providers = providers;
}
public String getProvider() {
return provider;
}
@@ -72,4 +142,60 @@ public class MagicAiProperties {
public void setModel(String model) {
this.model = model;
}
/**
* 单个提供商的配置
*/
public static class ProviderConfig {
private String label;
private String apiUrl;
private String apiKey;
private String model;
private List<String> models = new ArrayList<>();
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public String getApiUrl() {
return apiUrl;
}
public void setApiUrl(String apiUrl) {
this.apiUrl = apiUrl;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public List<String> getModels() {
return models;
}
public void setModels(List<String> models) {
this.models = models;
}
}
}

View File

@@ -22,6 +22,16 @@ public class AiChatRequest {
*/
private String currentCode;
/**
* 指定使用的提供商(由前端传入)
*/
private String provider;
/**
* 指定使用的模型(由前端传入)
*/
private String model;
public String getMessage() {
return message;
}
@@ -45,4 +55,20 @@ public class AiChatRequest {
public void setCurrentCode(String currentCode) {
this.currentCode = currentCode;
}
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
}

View File

@@ -5,6 +5,7 @@ import org.ssssssss.magicapi.ai.model.AiChatRequest;
import org.ssssssss.magicapi.ai.model.AiChatResponse;
import java.util.List;
import java.util.function.Consumer;
/**
* AI服务接口
@@ -12,101 +13,31 @@ import java.util.List;
public interface AiService {
/**
* 发送对话消息获取AI回复
*
* @param request 请求对象,包含消息和历史记录
* @return AI回复
* 发送对话消息获取AI回复(同步)
*/
AiChatResponse chat(AiChatRequest request);
/**
* 构建包含magic-script语法说明的系统提示词
* 流式对话逐块推送AI回复内容
* 默认实现降级为同步一次推送,子类可覆盖实现真正的流式
*/
default void chatStream(AiChatRequest request, Consumer<String> onToken) {
AiChatResponse resp = chat(request);
onToken.accept(resp.getMessage());
}
/**
* 构建系统提示词(根据用户消息动态匹配 Skill 模块)
*/
default String buildSystemPrompt(String userMessage) {
return SkillPromptLoader.buildPrompt(userMessage);
}
/**
* 构建系统提示词(无参兼容版本,加载默认 Skill
*/
default String buildSystemPrompt() {
return "你是一个magic-api脚本专家专门帮助用户编写magic-script脚本代码。\n\n" +
"magic-script 是magic-api框架使用的脚本引擎语法类似JavaScript但有以下特点\n\n" +
"## 基础语法\n" +
"```\n" +
"// 变量定义\n" +
"var name = 'hello'\n" +
"var age = 18\n\n" +
"// 字符串插值\n" +
"var message = `Hello ${name}, you are ${age} years old`\n\n" +
"// 条件语句\n" +
"if (age > 18) {\n" +
" return 'adult'\n" +
"} else {\n" +
" return 'minor'\n" +
"}\n\n" +
"// 循环\n" +
"for (var item in list) {\n" +
" // 处理每个元素\n" +
"}\n" +
"```\n\n" +
"## 数据库操作 (db模块)\n" +
"```\n" +
"// 查询列表\n" +
"return db.select('select * from user where status = ?', [1])\n\n" +
"// 分页查询\n" +
"return db.page('select * from user')\n\n" +
"// 查询单条记录\n" +
"return db.selectOne('select * from user where id = ?', [id])\n\n" +
"// 新增\n" +
"return db.insert('insert into user(name, age) values(?, ?)', [name, age])\n\n" +
"// 更新\n" +
"return db.update('update user set name = ? where id = ?', [name, id])\n\n" +
"// 删除\n" +
"return db.delete('delete from user where id = ?', [id])\n\n" +
"// 动态SQLMyBatis风格\n" +
"return db.select(\"\"\"\n" +
" select * from user\n" +
" <where>\n" +
" <if test=\"name != null\">and name like concat('%', #{name}, '%')</if>\n" +
" <if test=\"status != null\">and status = #{status}</if>\n" +
" </where>\n" +
"\"\"\")\n" +
"```\n\n" +
"## 请求参数获取\n" +
"```\n" +
"// 获取路径参数需在API路径中定义 /user/{id}\n" +
"var userId = id\n\n" +
"// 获取请求体参数POST请求\n" +
"var body = body\n\n" +
"// 获取请求头\n" +
"var token = request.getHeader('Authorization')\n" +
"```\n\n" +
"## 常用内置对象\n" +
"```\n" +
"// request 对象 - 获取请求信息\n" +
"request.getHeader('name') // 获取请求头\n" +
"request.getParameter('name') // 获取请求参数\n" +
"\n" +
"// response 对象 - 设置响应信息\n" +
"response.setHeader('name', 'value') // 设置响应头\n" +
"\n" +
"// session 对象 - 操作session\n" +
"session.setAttribute('key', value) // 设置session\n" +
"session.getAttribute('key') // 获取session\n" +
"```\n\n" +
"## 使用Spring Bean\n" +
"```\n" +
"// 导入Spring Bean\n" +
"import {userService} from 'spring'\n" +
"var user = userService.findById(id)\n" +
"```\n\n" +
"## 导入Java类\n" +
"```\n" +
"import java.util.Date\n" +
"var now = new Date()\n" +
"```\n\n" +
"## HTTP请求\n" +
"```\n" +
"import {http} from 'magic'\n" +
"var result = http.get('https://api.example.com/data')\n" +
"```\n\n" +
"请根据用户的需求生成符合magic-script语法的代码。\n" +
"生成代码时,使用代码块标记(```)包裹代码,并指定语言类型为 `magicscript`。\n" +
"代码要简洁、易懂,并添加必要的注释。";
return buildSystemPrompt("");
}
/**

View File

@@ -15,7 +15,14 @@ import org.ssssssss.magicapi.ai.model.AiChatMessage;
import org.ssssssss.magicapi.ai.model.AiChatRequest;
import org.ssssssss.magicapi.ai.model.AiChatResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
/**
* 阿里百炼DashScopeAI服务实现
@@ -37,13 +44,36 @@ public class DashScopeService implements AiService {
this.restTemplate = new RestTemplate();
}
private ArrayNode buildMessages(AiChatRequest request) {
ArrayNode messages = objectMapper.createArrayNode();
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt(request.getMessage()));
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
return messages;
}
@Override
public AiChatResponse chat(AiChatRequest request) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
ObjectNode model = requestBody.putObject("model");
model.put("name", this.model);
requestBody.put("model", this.model);
ObjectNode input = requestBody.putObject("input");
ArrayNode messages = input.putArray("messages");
@@ -51,7 +81,7 @@ public class DashScopeService implements AiService {
// 系统提示词
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt());
systemMsg.put("content", buildSystemPrompt(request.getMessage()));
// 代码上下文
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
@@ -109,4 +139,51 @@ public class DashScopeService implements AiService {
return new AiChatResponse("AI服务调用异常: " + e.getMessage());
}
}
@Override
public void chatStream(AiChatRequest request, Consumer<String> onToken) {
// 流式使用 DashScope 的 OpenAI 兼容端点
String streamUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("stream", true);
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
HttpURLConnection conn = (HttpURLConnection) new URL(streamUrl).openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
conn.setRequestProperty("Accept", "text/event-stream");
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6).trim();
if ("[DONE]".equals(data)) {
break;
}
JsonNode chunk = objectMapper.readTree(data);
String content = chunk.path("choices").path(0)
.path("delta").path("content").asText(null);
if (content != null) {
onToken.accept(content);
}
}
}
}
} catch (Exception e) {
logger.error("DashScope流式调用异常", e);
onToken.accept("\n\nAI服务流式调用异常: " + e.getMessage());
}
}
}

View File

@@ -15,7 +15,14 @@ import org.ssssssss.magicapi.ai.model.AiChatMessage;
import org.ssssssss.magicapi.ai.model.AiChatRequest;
import org.ssssssss.magicapi.ai.model.AiChatResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
/**
* DeepSeek AI服务实现兼容OpenAI接口格式
@@ -37,41 +44,37 @@ public class DeepSeekService implements AiService {
this.restTemplate = new RestTemplate();
}
private ArrayNode buildMessages(AiChatRequest request) {
ArrayNode messages = objectMapper.createArrayNode();
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt(request.getMessage()));
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
return messages;
}
@Override
public AiChatResponse chat(AiChatRequest request) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("stream", false);
ArrayNode messages = requestBody.putArray("messages");
// 系统提示词
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt());
// 代码上下文
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
// 历史消息
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
// 当前用户消息
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
@@ -103,4 +106,49 @@ public class DeepSeekService implements AiService {
return new AiChatResponse("AI服务调用异常: " + e.getMessage());
}
}
@Override
public void chatStream(AiChatRequest request, Consumer<String> onToken) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("stream", true);
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
conn.setRequestProperty("Accept", "text/event-stream");
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6).trim();
if ("[DONE]".equals(data)) {
break;
}
JsonNode chunk = objectMapper.readTree(data);
String content = chunk.path("choices").path(0)
.path("delta").path("content").asText(null);
if (content != null) {
onToken.accept(content);
}
}
}
}
} catch (Exception e) {
logger.error("DeepSeek流式调用异常", e);
onToken.accept("\n\nAI服务流式调用异常: " + e.getMessage());
}
}
}

View File

@@ -15,7 +15,14 @@ import org.ssssssss.magicapi.ai.model.AiChatMessage;
import org.ssssssss.magicapi.ai.model.AiChatRequest;
import org.ssssssss.magicapi.ai.model.AiChatResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
/**
* MiniMax AI服务实现
@@ -37,40 +44,37 @@ public class MiniMaxService implements AiService {
this.restTemplate = new RestTemplate();
}
private ArrayNode buildMessages(AiChatRequest request) {
ArrayNode messages = objectMapper.createArrayNode();
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt(request.getMessage()));
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
return messages;
}
@Override
public AiChatResponse chat(AiChatRequest request) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
ArrayNode messages = requestBody.putArray("messages");
// 系统提示词
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt());
// 代码上下文
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
// 历史消息
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
// 当前用户消息
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
requestBody.put("stream", false);
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
@@ -82,12 +86,26 @@ public class MiniMaxService implements AiService {
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, entity, String.class);
logger.info("MiniMax响应状态码: {}, 响应体: {}", response.getStatusCodeValue(), response.getBody());
if (response.getStatusCodeValue() != 200) {
logger.error("MiniMax接口请求失败状态码: {}", response.getStatusCodeValue());
return new AiChatResponse("AI服务请求失败状态码: " + response.getStatusCodeValue());
}
JsonNode responseJson = objectMapper.readTree(response.getBody());
// 检查 MiniMax 业务层错误
JsonNode baseResp = responseJson.path("base_resp");
if (baseResp != null && !baseResp.isMissingNode()) {
int statusCode = baseResp.path("status_code").asInt(0);
if (statusCode != 0) {
String statusMsg = baseResp.path("status_msg").asText("未知错误");
logger.error("MiniMax业务错误: code={}, msg={}", statusCode, statusMsg);
return new AiChatResponse("MiniMax错误: " + statusMsg);
}
}
String content = responseJson
.path("choices")
.path(0)
@@ -95,6 +113,11 @@ public class MiniMaxService implements AiService {
.path("content")
.asText("");
if (content.isEmpty()) {
logger.warn("MiniMax返回内容为空完整响应: {}", response.getBody());
return new AiChatResponse("MiniMax返回了空内容请检查模型配置或稍后重试。");
}
return new AiChatResponse(content);
} catch (Exception e) {
@@ -102,4 +125,57 @@ public class MiniMaxService implements AiService {
return new AiChatResponse("AI服务调用异常: " + e.getMessage());
}
}
@Override
public void chatStream(AiChatRequest request, Consumer<String> onToken) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("stream", true);
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
conn.setRequestProperty("Accept", "text/event-stream");
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6).trim();
if ("[DONE]".equals(data)) {
break;
}
JsonNode chunk = objectMapper.readTree(data);
// 检查业务错误
JsonNode baseResp = chunk.path("base_resp");
if (!baseResp.isMissingNode() && baseResp.path("status_code").asInt(0) != 0) {
String errMsg = baseResp.path("status_msg").asText("未知错误");
logger.error("MiniMax流式业务错误: {}", errMsg);
onToken.accept("\n\nMiniMax错误: " + errMsg);
break;
}
String content = chunk.path("choices").path(0)
.path("delta").path("content").asText(null);
if (content != null) {
onToken.accept(content);
}
}
}
}
} catch (Exception e) {
logger.error("MiniMax流式调用异常", e);
onToken.accept("\n\nAI服务流式调用异常: " + e.getMessage());
}
}
}

View File

@@ -15,7 +15,14 @@ import org.ssssssss.magicapi.ai.model.AiChatMessage;
import org.ssssssss.magicapi.ai.model.AiChatRequest;
import org.ssssssss.magicapi.ai.model.AiChatResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
/**
* OpenAI兼容接口的AI服务实现支持OpenAI、Azure OpenAI、本地大模型等
@@ -31,47 +38,43 @@ public class OpenAiService implements AiService {
private final RestTemplate restTemplate;
public OpenAiService(String apiUrl, String apiKey, String model) {
this.apiUrl = apiUrl;
this.apiUrl = apiUrl != null && !apiUrl.isEmpty() ? apiUrl : "https://api.openai.com/v1/chat/completions";
this.apiKey = apiKey;
this.model = model;
this.model = model != null && !model.isEmpty() ? model : "gpt-4o";
this.restTemplate = new RestTemplate();
}
private ArrayNode buildMessages(AiChatRequest request) {
ArrayNode messages = objectMapper.createArrayNode();
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt(request.getMessage()));
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
return messages;
}
@Override
public AiChatResponse chat(AiChatRequest request) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("stream", false);
ArrayNode messages = requestBody.putArray("messages");
// 系统提示词
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt());
// 如果有当前代码上下文,加入 system 消息
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
// 历史消息
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
// 当前用户消息
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
@@ -103,4 +106,49 @@ public class OpenAiService implements AiService {
return new AiChatResponse("AI服务调用异常: " + e.getMessage());
}
}
@Override
public void chatStream(AiChatRequest request, Consumer<String> onToken) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("stream", true);
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
conn.setRequestProperty("Accept", "text/event-stream");
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6).trim();
if ("[DONE]".equals(data)) {
break;
}
JsonNode chunk = objectMapper.readTree(data);
String content = chunk.path("choices").path(0)
.path("delta").path("content").asText(null);
if (content != null) {
onToken.accept(content);
}
}
}
}
} catch (Exception e) {
logger.error("OpenAI流式调用异常", e);
onToken.accept("\n\nAI服务流式调用异常: " + e.getMessage());
}
}
}

View File

@@ -15,7 +15,14 @@ import org.ssssssss.magicapi.ai.model.AiChatMessage;
import org.ssssssss.magicapi.ai.model.AiChatRequest;
import org.ssssssss.magicapi.ai.model.AiChatResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.Consumer;
/**
* 智谱AIGLM服务实现
@@ -37,40 +44,37 @@ public class ZhipuAiService implements AiService {
this.restTemplate = new RestTemplate();
}
private ArrayNode buildMessages(AiChatRequest request) {
ArrayNode messages = objectMapper.createArrayNode();
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt(request.getMessage()));
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
return messages;
}
@Override
public AiChatResponse chat(AiChatRequest request) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
ArrayNode messages = requestBody.putArray("messages");
// 系统提示词
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", buildSystemPrompt());
// 代码上下文
if (request.getCurrentCode() != null && !request.getCurrentCode().trim().isEmpty()) {
ObjectNode codeContextMsg = messages.addObject();
codeContextMsg.put("role", "system");
codeContextMsg.put("content", "当前编辑器中的代码:\n```magicscript\n" + request.getCurrentCode() + "\n```\n请在回答时结合以上代码上下文。");
}
// 历史消息
List<AiChatMessage> history = request.getHistory();
if (history != null) {
for (AiChatMessage msg : history) {
ObjectNode historyMsg = messages.addObject();
historyMsg.put("role", msg.getRole());
historyMsg.put("content", msg.getContent());
}
}
// 当前用户消息
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", request.getMessage());
requestBody.put("stream", false);
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
@@ -102,4 +106,49 @@ public class ZhipuAiService implements AiService {
return new AiChatResponse("AI服务调用异常: " + e.getMessage());
}
}
@Override
public void chatStream(AiChatRequest request, Consumer<String> onToken) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("stream", true);
requestBody.set("messages", buildMessages(request));
String jsonBody = objectMapper.writeValueAsString(requestBody);
HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
conn.setRequestProperty("Accept", "text/event-stream");
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6).trim();
if ("[DONE]".equals(data)) {
break;
}
JsonNode chunk = objectMapper.readTree(data);
String content = chunk.path("choices").path(0)
.path("delta").path("content").asText(null);
if (content != null) {
onToken.accept(content);
}
}
}
}
} catch (Exception e) {
logger.error("智谱AI流式调用异常", e);
onToken.accept("\n\nAI服务流式调用异常: " + e.getMessage());
}
}
}

View File

@@ -1,31 +1,48 @@
package org.ssssssss.magicapi.ai.web;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.ssssssss.magicapi.ai.model.AiChatRequest;
import org.ssssssss.magicapi.ai.model.AiChatResponse;
import org.ssssssss.magicapi.ai.service.AiService;
import org.ssssssss.magicapi.ai.service.AiServiceManager;
import org.ssssssss.magicapi.core.annotation.Valid;
import org.ssssssss.magicapi.core.config.MagicConfiguration;
import org.ssssssss.magicapi.core.model.JsonBean;
import org.ssssssss.magicapi.core.web.MagicController;
import org.ssssssss.magicapi.core.web.MagicExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* AI Coding 控制器
*/
public class MagicAiController extends MagicController implements MagicExceptionHandler {
private final AiService aiService;
private static final Logger logger = LoggerFactory.getLogger(MagicAiController.class);
public MagicAiController(MagicConfiguration configuration, AiService aiService) {
private final AiServiceManager aiServiceManager;
private final ExecutorService executor = Executors.newCachedThreadPool();
private final ObjectMapper objectMapper = new ObjectMapper();
public MagicAiController(MagicConfiguration configuration, AiServiceManager aiServiceManager) {
super(configuration);
this.aiService = aiService;
this.aiServiceManager = aiServiceManager;
}
/**
* AI对话接口
* AI对话接口同步provider和model由前端每次请求携带
*/
@PostMapping("/ai/chat")
@ResponseBody
@@ -34,7 +51,53 @@ public class MagicAiController extends MagicController implements MagicException
if (request.getMessage() == null || request.getMessage().trim().isEmpty()) {
return new JsonBean<>(400, "消息内容不能为空");
}
AiChatResponse response = aiService.chat(request);
AiChatResponse response = aiServiceManager.getService(request.getProvider(), request.getModel()).chat(request);
return new JsonBean<>(response);
}
/**
* AI对话接口流式SSE逐块推送AI回复
*/
@PostMapping(value = "/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@ResponseBody
@Valid
public SseEmitter chatStream(@RequestBody AiChatRequest request) {
SseEmitter emitter = new SseEmitter(120_000L);
executor.execute(() -> {
try {
AiService service = aiServiceManager.getService(request.getProvider(), request.getModel());
service.chatStream(request, token -> {
try {
// 手动 JSON 编码,确保换行符等被转义为 \n
// StringHttpMessageConverter 优先级高于 Jackson直接写已编码的字符串
String jsonToken = objectMapper.writeValueAsString(token);
emitter.send(SseEmitter.event().data(jsonToken));
} catch (IOException e) {
emitter.completeWithError(e);
}
});
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (Exception e) {
logger.error("流式AI服务调用异常", e);
try {
emitter.send(SseEmitter.event().name("error").data(e.getMessage()));
emitter.complete();
} catch (IOException ignored) {
emitter.completeWithError(e);
}
}
});
return emitter;
}
/**
* 获取已配置的提供商列表(含模型列表),供前端选择器使用
*/
@PostMapping("/ai/config/get")
@ResponseBody
@Valid
public JsonBean<List<Map<String, Object>>> getProviders() {
return new JsonBean<>(aiServiceManager.getConfiguredProviders());
}
}