init commit

This commit is contained in:
feie9454
2025-09-10 17:39:23 +08:00
commit 21a21c1dfd
28 changed files with 2142 additions and 0 deletions
+1375
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

+270
View File
@@ -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>
+146
View File
@@ -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`' },
],
},
]
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+12
View File
@@ -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');
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />