init commit
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 402 KiB |
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
// Props:从 App 传入的实时电路状态(完整、可序列化)
|
||||
// 允许任何结构,但推荐使用你在 App.vue 的 SaveFile 形状
|
||||
const props = defineProps<{
|
||||
state: any
|
||||
}>()
|
||||
|
||||
// 向父组件发出事件:可选,用于保存/载入 AI 偏好设置
|
||||
const emit = defineEmits<{
|
||||
(e: 'error', err: string): void
|
||||
}>()
|
||||
|
||||
let client: OpenAI = new OpenAI({
|
||||
apiKey: "sk-9aXW6M6V8ddd3d02A3d4T3BLBkFJ8D22a56Efcb14d43b559",
|
||||
baseURL: "https://c-z0-api-01.hash070.com/v1",
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
|
||||
|
||||
// 对话历史(UI 可见)。hasState 用于标记该条消息请求时附带了电路状态
|
||||
type ChatRole = 'user' | 'assistant' | 'system'
|
||||
type ChatMessage = { role: ChatRole; content: string; hasState?: boolean }
|
||||
|
||||
// 为降低 token 压力,历史消息在发送给模型时会做截断,但 UI 全量保留
|
||||
const messages = ref<ChatMessage[]>([
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是电路虚拟实验室的智能助手。你能阅读用户的电路JSON状态,帮助分析连线是否闭合、元件参数是否合理,解释电压/电流结果,并给出下一步建议。回答保持简洁、条理清晰。'
|
||||
}
|
||||
])
|
||||
const input = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
// 把电路状态格式化成简洁摘要(避免无上限增长)
|
||||
function summarizeState(s: any): string {
|
||||
try {
|
||||
if (!s) return '无状态'
|
||||
const full = JSON.stringify(s)
|
||||
// 将完整电路状态作为隐藏的 system 上下文传递
|
||||
return `电路状态: ${full}`
|
||||
} catch (e) {
|
||||
return '状态序列化失败'
|
||||
}
|
||||
}
|
||||
|
||||
// 发送问题
|
||||
async function ask() {
|
||||
const question = input.value.trim()
|
||||
if (!question) return
|
||||
if (!client) {
|
||||
emit('error', '请先提供 OpenAI API Key')
|
||||
return
|
||||
}
|
||||
// UI 只展示问题本身,不展示电路 JSON;用 hasState 指示
|
||||
const uiMsg: ChatMessage = { role: 'user', content: question, hasState: true }
|
||||
messages.value.push(uiMsg)
|
||||
input.value = ''
|
||||
await nextTick(); scrollToBottom()
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
// 构造发送给模型的消息:人格 + 本次完整电路状态 + 近期对话
|
||||
const persona = messages.value[0]
|
||||
const stateMsg = { role: 'system' as const, content: summarizeState(props.state) }
|
||||
const history = messages.value.slice(1).map(m => ({ role: m.role, content: m.content }))
|
||||
const recent = history.slice(-10)
|
||||
|
||||
// 流式输出
|
||||
const stream = await client.chat.completions.create({
|
||||
model: 'gpt-5',
|
||||
temperature: 1,
|
||||
reasoning_effort: 'minimal',
|
||||
stream: true,
|
||||
messages: [persona, stateMsg, ...recent]
|
||||
})
|
||||
|
||||
const assistantMsg: ChatMessage = { role: 'assistant', content: '' }
|
||||
messages.value.push(assistantMsg)
|
||||
for await (const part of stream as any) {
|
||||
const delta: string = part?.choices?.[0]?.delta?.content ?? ''
|
||||
if (delta) assistantMsg.content += delta
|
||||
await nextTick(); scrollToBottom()
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || String(err)
|
||||
emit('error', msg)
|
||||
messages.value.push({ role: 'assistant', content: `出错:${msg}` })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// UI:滚动到底
|
||||
const listRef = ref<HTMLDivElement | null>(null)
|
||||
function scrollToBottom() {
|
||||
const el = listRef.value
|
||||
if (!el) return
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(scrollToBottom))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ai-panel">
|
||||
<header class="ai-header">
|
||||
<div class="title">AI 助手</div>
|
||||
</header>
|
||||
|
||||
<div class="chat-list" ref="listRef">
|
||||
<div v-for="(m, i) in messages" :key="i" class="msg" :class="m.role">
|
||||
<div class="bubble" v-text="m.content" />
|
||||
<div v-if="m.hasState" class="attachments">
|
||||
<span class="chip" title="请求中已附带完整电路状态(不会在聊天中显示)">circuit.json</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="ai-input">
|
||||
<textarea v-model="input" class="q-input" rows="2" :placeholder="sending ? '发送中…' : '询问电路分析、错误排查、参数建议…'"
|
||||
:disabled="sending" @keydown.enter.exact.prevent="ask" />
|
||||
<button class="send-btn" :disabled="sending" @click="ask">发送</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
height: 100%;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.ai-header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.key-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.key-input {
|
||||
min-width: 260px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 80%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.msg.system {
|
||||
justify-content: center;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.msg.user {
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.msg.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
white-space: pre-wrap;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.msg.system .bubble {
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.msg.user .bubble {
|
||||
background: #dbeafe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.msg.assistant .bubble {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.ai-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.q-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: .6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
// 显式导入所有电路元件图片,并定义其基本属性
|
||||
|
||||
import battery from './circuit-ele/battery.png'
|
||||
import capacitor from './circuit-ele/capacitor.png'
|
||||
import inductor from './circuit-ele/inductor.png'
|
||||
import lightBulb from './circuit-ele/light_bulb.png'
|
||||
import lightBulbGrow from './circuit-ele/light_bulb_grow.png'
|
||||
import resistor from './circuit-ele/resistor.png'
|
||||
import switchOff from './circuit-ele/switch_off.png'
|
||||
import switchOn from './circuit-ele/switch_on.png'
|
||||
import meter from './circuit-ele/meter.png'
|
||||
|
||||
export type ConnectionPoint = {
|
||||
// 以百分比(0..1)定义在图片盒子内的位置
|
||||
x: number
|
||||
y: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type CircuitElement = {
|
||||
key: string
|
||||
name: string
|
||||
url: string
|
||||
defaultSize?: number // 渲染尺寸(像素),可选
|
||||
connectionPoints: ConnectionPoint[]
|
||||
propertySchemas?: PropertySchema[]
|
||||
stateImages?: { on: string; off: string }
|
||||
// 预设:一键应用一组属性值
|
||||
preset?: Preset[]
|
||||
}
|
||||
|
||||
export type PropertySchema = {
|
||||
key: string
|
||||
label: string
|
||||
type: 'number' | 'text'
|
||||
unit?: string
|
||||
default: number | string
|
||||
}
|
||||
|
||||
// 预设定义:展示名称 + 要覆盖的属性值
|
||||
export type Preset = {
|
||||
name: string
|
||||
propertyValues: Record<string, number | string>
|
||||
}
|
||||
|
||||
export const elements: CircuitElement[] = [
|
||||
{
|
||||
key: 'battery',
|
||||
name: '电池',
|
||||
url: battery,
|
||||
defaultSize: 110,
|
||||
connectionPoints: [
|
||||
{ x: 0.05, y: 0.5, name: 'A' }, // 左侧
|
||||
{ x: 0.95, y: 0.5, name: 'B' }, // 右侧
|
||||
],
|
||||
propertySchemas: [
|
||||
{ key: 'voltage', label: '电压', type: 'number', unit: 'V', default: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'switch',
|
||||
name: '开关',
|
||||
url: switchOff,
|
||||
defaultSize: 130,
|
||||
connectionPoints: [
|
||||
{ x: 0.05, y: 0.7, name: 'A' },
|
||||
{ x: 0.83, y: 0.7, name: 'B' },
|
||||
],
|
||||
// 点击元件切换 on/off 显示
|
||||
stateImages: { on: switchOn, off: switchOff },
|
||||
// 开关没有额外可编辑属性
|
||||
},
|
||||
{
|
||||
key: 'light_bulb',
|
||||
name: '灯泡',
|
||||
url: lightBulb,
|
||||
defaultSize: 80,
|
||||
connectionPoints: [
|
||||
{ x: 0.5, y: 1, name: 'A' },
|
||||
{ x: 0.72, y: 0.8, name: 'B' },
|
||||
],
|
||||
propertySchemas: [
|
||||
{ key: 'resistance', label: '电阻', type: 'number', unit: 'Ω', default: 20 },
|
||||
],
|
||||
// 当电流超过阈值时在前端根据 state 切换图片
|
||||
stateImages: { on: lightBulbGrow, off: lightBulb },
|
||||
},
|
||||
{
|
||||
key: 'capacitor',
|
||||
name: '电容',
|
||||
url: capacitor,
|
||||
defaultSize: 120,
|
||||
connectionPoints: [
|
||||
{ x: 0.6, y: 0.96, name: 'A' },
|
||||
{ x: 0.4, y: 0.9, name: 'B' },
|
||||
],
|
||||
propertySchemas: [
|
||||
{ key: 'capacitance', label: '电容', type: 'number', unit: 'F', default: 0.000001 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'inductor',
|
||||
name: '电感',
|
||||
url: inductor,
|
||||
defaultSize: 100,
|
||||
connectionPoints: [
|
||||
{ x: 0.05, y: 0.5, name: 'A' },
|
||||
{ x: 0.95, y: 0.5, name: 'B' },
|
||||
],
|
||||
propertySchemas: [
|
||||
{ key: 'inductance', label: '电感', type: 'number', unit: 'H', default: 0.001 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'resistor',
|
||||
name: '电阻',
|
||||
url: resistor,
|
||||
defaultSize: 110,
|
||||
connectionPoints: [
|
||||
{ x: 0.05, y: 0.5, name: 'A' },
|
||||
{ x: 0.95, y: 0.5, name: 'B' },
|
||||
],
|
||||
propertySchemas: [
|
||||
{ key: 'resistance', label: '电阻', type: 'number', unit: 'Ω', default: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'meter',
|
||||
name: '表',
|
||||
url: meter,
|
||||
defaultSize: 150,
|
||||
connectionPoints: [
|
||||
{ x: 0.25, y: 1, name: 'A' },
|
||||
{ x: 0.45, y: 1, name: 'B' },
|
||||
],
|
||||
preset:[{
|
||||
name: '电流表', propertyValues: { resistance: 1, renderFunc: '(i) => `${(i * 1000).toFixed(2)} mA`' }
|
||||
},{
|
||||
name: '电压表', propertyValues: { resistance: 1000000, renderFunc: '(i, r) => `${(i * r).toFixed(2)} V`' }
|
||||
}],
|
||||
propertySchemas: [
|
||||
{ key: 'resistance', label: '电阻', type: 'number', unit: 'Ω', default: 1 },
|
||||
{ key: 'renderFunc', label: '显示函数', type: 'text', default: '(i) => `${(i * 1000).toFixed(2)} mA`' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,12 @@
|
||||
body{
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "meter-font";
|
||||
src: url('./assets/Jersey20-Regular.ttf') format('truetype');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||