This commit is contained in:
539943419 committed 2025-03-21 11:55:33 +08:00
1 parent 286dd8e386
commit 01085a3ba5
12 files changed
+1114 -189

No files matched your search

+5 -1
View File
@@ -35,4 +35,8 @@ auto-imports.d.ts
components.d.ts
src/plugins/onnx_demo_*
# src/plugins/onnx_demo_*
public/test
doc
-118
View File
@@ -26,121 +26,3 @@
## 碎碎念:
写的真潦草
## 让cursor生成了个架构图,可能不是很专业,不过我看了感觉没啥问题:
```
┌────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ miaoDirectory前端架构 │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
▲
│
│ HTTP API通信
│
┌────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 后端服务 │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 应用入口 (App.vue) │
│ ┌──────────────────────────┐ ┌────────────────────────────┐ ┌────────────────────────────────┐ │
│ │ 全局状态初始化 │ │ 全局消息提供器 │ │ 全局导航菜单 │ │
│ │ (初始化各个系统核心实例) │ │ (miaoAlertTipProvider) │ │ │ │
│ └──────────────┬───────────┘ └─────────────┬──────────────┘ └────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ ┌────────────────────────────┐ │
│ │ 视图控制器渲染 │ │ 视图容器渲染 │ │
│ │ (ViewController) │ │ (ViewContainer) │ │
│ └──────────────────────────┘ └────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
│ │
│ │
▼ ▼
┌────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 多页面系统 │
│ │
│ ┌──────────────────────────────────────────┐ ┌───────────────────────────────────────────────┐ │
│ │ VirtualPages类 │ │ VirtualPage类 │ │
│ │ ┌────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ _views │ │ │ │ component: 页面使用的组件 │ │ │
│ │ │ (存储所有虚拟页面的响应式数组) │ │ │ │ name: 页面名 │ │ │
│ │ └────────────────────────────────────┘ │ │ │ visible: 是否可见 │ │ │
│ │ │ │ │ currentDirectories: 当前使用的目录 │ │ │
│ │ ┌────────────────────────────────────┐ │ │ │ currentFiles: 当前使用的文件 │ │ │
│ │ │ getIndex(): 获取页面索引 │ │ │ │ id: 唯一标识符 │ │ │
│ │ │ getView(): 根据ID获取页面 │ │ │ │ color: 页面颜色 │ │ │
│ │ │ push(): 添加新页面 │◄──┼────┼─┤ title: 页面标题 │ │ │
│ │ │ deleteView(): 删除页面 │ │ │ │ exitConfirm: 关闭时是否需要确认 │ │ │
│ │ │ find(): 查找页面 │ │ │ │ allowCopy: 是否允许复制页面 │ │ │
│ │ └────────────────────────────────────┘ │ │ └─────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────┘ │ │ │
│ ▲ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ │ setTitle(): 设置标题 │ │ │
│ │ │ │ switchShow(): 切换显示状态 │ │ │
│ │ │ │ refresh(): 刷新页面 │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │
│ │ └───────────────────────────────────────────────┘ │
│ │ ▲ │
│ │ │ │
│ useVirtualPages() │ │
│ (全局单例访问钩子) │ │
│ │ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
│ │
│ │
▼ │
┌────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 插件系统 │
│ │
│ ┌──────────────────────────────────────────┐ ┌───────────────────────────────────────────────┐ │
│ │ PluginCenter类 │ │ 插件接口定义 │ │
│ │ ┌────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ pluginsMap │ │ │ │ name: 插件名称 │ │ │
│ │ │ (存储已注册插件的映射表) │ │ │ │ icon: 插件图标 │ │ │
│ │ └────────────────────────────────────┘ │ │ │ disable: 是否禁用 │ │ │
│ │ │ │ │ group: 所属插件组 │ │ │
│ │ ┌────────────────────────────────────┐ │ │ │ priority: 优先级 │ │ │
│ │ │ register(): 注册插件 │ │ │ │ filter(): 适用性过滤函数 │ │ │
│ │ │ deregister(): 注销插件 │ │ │ │ func(): 插件主函数 │ │ │
│ │ │ registerComponent(): 注册组件插件 │ │ │ └─────────────────────────────────────────┘ │ │
│ │ │ getUsablePlugin(): 获取可用插件 │───┼────┼─► 创建虚拟页面 │ │
│ │ │ usePlugin(): 使用插件 │ │ │ │ │
│ │ └────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────┐ │ │
│ └──────────────────────────────────────────┘ │ │ 插件分组(PluginGroup) │ │ │
│ ▲ │ │ │ │ │
│ │ │ │ default: 默认组 │ │ │
│ │ │ │ mainMenu: 主菜单组 │ │ │
│ │ │ │ immediately: 立即执行组 │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │
│ │ └───────────────────────────────────────────────┘ │
│ │ │
│ usePluginCenter() │
│ (全局单例访问钩子) │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
│
│
▼
┌────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 虚拟文件系统 │
│ │
│ ┌──────────────────────────────────────────┐ ┌───────────────────────────────────────────────┐ │
│ │ VirtualDirectory类 │ │ VirtualFile类 │ │
│ │ ┌────────────────────────────────────┐ │ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ name: 目录名 │ │ │ │ name: 文件名 │ │ │
│ │ │ path: 完整路径 │ │ │ │ size: 文件大小 │ │ │
│ │ │ stats: 文件状态信息 │ │ │ │ stats: 文件状态信息 │ │ │
│ │ │ files: 文件列表 │◄──┼────┼─► parent: 父目录 │ │ │
│ │ │ directories: 子目录列表 │ │ │ │ id: 唯一标识符 │ │ │
│ │ │ parent?: 父目录 │ │ │ │ path: 完整路径(计算属性) │ │ │
│ │ └────────────────────────────────────┘ │ │ └─────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────┘ └───────────────────────────────────────────────┘ │
│ ▲ │
│ │ │
│ │ │
│ useRootVDirectory() │
│ (获取根目录实例钩子) │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
+3 -3
View File
@@ -11,14 +11,14 @@ cd ./src-server
echo "building amd64 win"
go env -w GOOS=windows GOARCH=amd64
go build -o ../miao-directory-amd64-win.exe
go build -ldflags="-s -w" -o ../miao-directory-amd64-win.exe
echo "building amd64 linux"
go env -w GOOS=linux GOARCH=amd64
go build -o ../miao-directory-amd64-linux
go build -ldflags="-s -w" -o ../miao-directory-amd64-linux
# go env -w GOOS=linux GOARCH=arm64
# go build -o ../miao-directory-arm64-linux
# go build -ldflags="-s -w" -o ../miao-directory-arm64-linux
echo "build finish"
+3
View File
@@ -72,6 +72,7 @@ func GetHandler(c *gin.Context) {
switch ext {
case ".js":
case ".cjs":
case ".mjs":
c.Writer.Header().Set("Content-Type", "application/javascript")
case ".css":
c.Writer.Header().Set("Content-Type", "text/css")
@@ -79,6 +80,8 @@ func GetHandler(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "text/html")
case ".json":
c.Writer.Header().Set("Content-Type", "application/json")
case ".wasm":
c.Writer.Header().Set("Content-Type", "application/wasm")
default:
c.Writer.Header().Set("Content-Type", "application/octet-stream")
}
+132 -38
View File
@@ -5,6 +5,31 @@ import config from '@/config'
const miaoFetchApi = useMiaoFetchApi()
/**
* 轮询事件直到完成
* @param eventId 事件ID
* @param maxRetries 最大重试次数
* @param interval 轮询间隔(毫秒)
* @returns 事件结果
*/
async function pollEventUntilComplete(eventId: number, maxRetries = 100, interval = 300) {
let _count = 0;
while (_count < maxRetries) {
try {
const { response } = miaoFetchApi.query(eventId);
const result = await response;
if (result.status === 'success' || result.status === 'failed') {
return result;
}
_count++;
await new Promise(_ => setTimeout(_, interval));
} catch (error) {
throw error;
}
}
throw new Error('poll timeout');
}
export class VirtualRemoteFile extends VirtualFileBase {
constructor(info: file, parent: VirtualDirectoryBase) {
super(info.name, info.size, info.stats, parent)
@@ -20,8 +45,13 @@ export class VirtualRemoteFile extends VirtualFileBase {
retry: 5
});
const id = (await response).eventId;
const { response: res } = miaoFetchApi.query(id);
const eventResult = await res;
const eventResult = await pollEventUntilComplete(id);
if (eventResult.status === 'success' && this.parent) {
await this.parent.update(); // 操作成功后更新父目录
}
return eventResult.status === 'success';
} catch (error) {
console.error('删除文件失败:', error);
@@ -39,8 +69,13 @@ export class VirtualRemoteFile extends VirtualFileBase {
retry: 5
});
const id = (await response).eventId;
const { response: res } = miaoFetchApi.query(id);
const eventResult = await res;
const eventResult = await pollEventUntilComplete(id);
if (eventResult.status === 'success' && this.parent) {
await this.parent.update(); // 操作成功后更新父目录
}
return eventResult.status === 'success';
} catch (error) {
console.error('重命名文件失败:', error);
@@ -57,12 +92,13 @@ export class VirtualRemoteFile extends VirtualFileBase {
const _from = this.parent;
const { response } = miaoFetchApi.cut(this, targetDir);
const id = (await response).eventId;
const { response: res } = miaoFetchApi.query(id);
const eventResult = await res;
const eventResult = await pollEventUntilComplete(id);
const success = eventResult.status === 'success';
if (success) {
_from.update();
if (_from) await _from.update(); // 更新源目录
await targetDir.update(); // 更新目标目录
}
return success;
@@ -79,40 +115,87 @@ export class VirtualRemoteDirectory extends VirtualDirectoryBase {
}
get url(): string {
return `${config.api.get}${this.path}/`
return `${config.api.get}${this.path}`
}
updateContent(content: (file | directory)[]) {
this._isUpdated = true
// 创建名称到内容项的映射,O(n)
const contentMap = new Map<string, file | directory>();
content.forEach(item => contentMap.set(item.name, item));
const files = content.filter(item => item.type === 'file') as file[];
const directories = content.filter(item => item.type === 'directory') as directory[];
// 使用Set存储所有内容项的名称,用于O(1)查找,O(n)
const itemNamesSet = new Set(content.map(v => v.name));
this.diffFiles(files);
this.diffDirectories(directories);
}
// 删除不再存在的文件和目录,O(m)
this.files = this.files.filter(file => itemNamesSet.has(file.name));
this.directories = this.directories.filter(dir => itemNamesSet.has(dir.name));
/**
* 文件diff算法,类似Vue的diff
* @param newFiles 新文件列表
*/
private diffFiles(newFiles: file[]) {
if (this.files.length === 0) {
this.files = newFiles.map(file => new VirtualRemoteFile(file, this));
return;
}
// 创建现有文件和目录的名称集合,O(m)
const existingFileNames = new Set(this.files.map(file => file.name));
const existingDirNames = new Set(this.directories.map(dir => dir.name));
const oldFiles = [...this.files];
const newFilesList: VirtualRemoteFile[] = [];
// 添加新文件和目录,O(n)
content.forEach(item => {
// 如果是文件且不存在于现有文件中
if (item.type === 'file' && !existingFileNames.has(item.name)) {
const nvf = new VirtualRemoteFile(item as file, this);
this.files.push(nvf);
const oldKeyMap = new Map<string, VirtualRemoteFile>();
oldFiles.forEach(file => oldKeyMap.set(file.name, file as VirtualRemoteFile));
for (let i = 0; i < newFiles.length; i++) {
const newFile = newFiles[i];
const key = newFile.name;
const oldFile = oldKeyMap.get(key);
if (oldFile) {
oldFile.size = newFile.size;
oldFile.stats = newFile.stats;
newFilesList.push(oldFile);
// 从旧映射中删除,表示已处理
oldKeyMap.delete(key);
} else {
newFilesList.push(new VirtualRemoteFile(newFile, this));
}
// 如果是目录且不存在于现有目录中
else if (item.type === 'directory' && !existingDirNames.has(item.name)) {
const nvd = new VirtualRemoteDirectory(item as directory, this);
this.directories.push(nvd);
}
this.files = newFilesList;
}
/**
* 目录diff算法,类似Vue的diff
* @param newDirs 新目录列表
*/
private diffDirectories(newDirs: directory[]) {
if (this.directories.length === 0) {
this.directories = newDirs.map(dir => new VirtualRemoteDirectory(dir, this));
return;
}
const oldDirs = [...this.directories];
const newDirsList: VirtualRemoteDirectory[] = [];
const oldKeyMap = new Map<string, VirtualRemoteDirectory>();
oldDirs.forEach(dir => oldKeyMap.set(dir.name, dir as VirtualRemoteDirectory));
for (let i = 0; i < newDirs.length; i++) {
const newDir = newDirs[i];
const key = newDir.name;
const oldDir = oldKeyMap.get(key);
if (oldDir) {
oldDir.stats = newDir.stats;
newDirsList.push(oldDir);
oldKeyMap.delete(key);
} else {
newDirsList.push(new VirtualRemoteDirectory(newDir, this));
}
});
}
this.directories = newDirsList;
}
async update(): Promise<void> {
@@ -126,8 +209,13 @@ export class VirtualRemoteDirectory extends VirtualDirectoryBase {
retry: 5
});
const id = (await response).eventId;
const { response: res } = miaoFetchApi.query(id);
const eventResult = await res;
const eventResult = await pollEventUntilComplete(id);
if (eventResult.status === 'success' && this.parent) {
await this.parent.update(); // 操作成功后更新父目录
}
return eventResult.status === 'success';
} catch (error) {
console.error('删除目录失败:', error);
@@ -145,8 +233,13 @@ export class VirtualRemoteDirectory extends VirtualDirectoryBase {
retry: 5
});
const id = (await response).eventId;
const { response: res } = miaoFetchApi.query(id);
const eventResult = await res;
const eventResult = await pollEventUntilComplete(id);
if (eventResult.status === 'success' && this.parent) {
await this.parent.update(); // 操作成功后更新父目录
}
return eventResult.status === 'success';
} catch (error) {
console.error('重命名目录失败:', error);
@@ -168,12 +261,13 @@ export class VirtualRemoteDirectory extends VirtualDirectoryBase {
const _from = this.parent;
const { response } = miaoFetchApi.cut(this, targetDir);
const id = (await response).eventId;
const { response: res } = miaoFetchApi.query(id);
const eventResult = await res;
const eventResult = await pollEventUntilComplete(id);
const success = eventResult.status === 'success';
if (success && _from) {
_from.update();
if (success) {
if (_from) await _from.update(); // 更新源目录
await targetDir.update(); // 更新目标目录
}
return success;
+30 -25
View File
@@ -10,16 +10,16 @@
</div>
<div v-else class="chat-main">
<div class="chat-history" ref="chatHistoryRef">
<div v-for="(message, index) in chatHistory" :key="index"
:class="['message', message.role === 'user' ? 'user-message' : 'ai-message']">
<div class="message-content" v-if="message.role === 'user'">{{ message.content }}</div>
<div class="message-content markdown-content" v-else v-html="renderMarkdown(message.content)"></div>
</div>
<!-- 实时生成的内容 -->
<div v-if="generatingMessage" class="message ai-message">
<div class="message-content markdown-content" v-html="renderMarkdown(currentGeneratedText)"></div>
<div class="generating-indicator"><span>.</span><span>.</span><span>.</span></div>
</div>
<div v-for="(message, index) in chatHistory" :key="index"
:class="['message', message.role === 'user' ? 'user-message' : 'ai-message']">
<div class="message-content" v-if="message.role === 'user'">{{ message.content }}</div>
<div class="message-content markdown-content" v-else v-html="renderMarkdown(message.content)"></div>
</div>
<!-- 实时生成的内容 -->
<div v-if="generatingMessage" class="message ai-message">
<div class="message-content markdown-content" v-html="renderMarkdown(currentGeneratedText)"></div>
<div class="generating-indicator"><span>.</span><span>.</span><span>.</span></div>
</div>
</div>
<div class="chat-input">
<textarea v-model="userInput" @keydown.enter.prevent="sendMessage" placeholder="输入消息..."
@@ -96,7 +96,7 @@ const initializeModel = async () => {
try {
const modelFile = currentFiles.value[0];
const modelName = modelFile.name.split('.model.bin')[0];
const modelName = modelFile.name.split('.model.bin')[0] && modelFile.name.split('.model.task')[0];
// 第一阶段:开始加载
updateLoadingProgress(5, '正在加载MediaPipe LLM引擎...');
@@ -125,7 +125,7 @@ const initializeModel = async () => {
// 初始化FilesetResolver
updateLoadingProgress(20, '正在初始化MediaPipe文件解析器...');
const genai = await FilesetResolver.forGenAiTasks(
currentDirectories.value[0].url ?? "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-genai@latest/wasm"
currentDirectories.value[0]?.url ?? "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-genai@latest/wasm"
);
// 第二阶段:文件解析器加载完成
@@ -221,8 +221,8 @@ const sendMessage = async () => {
// 累加部分结果并更新UI
currentGeneratedText.value += partialResult;
const banStart = [',', ',']
for(const ban of banStart) {
if(currentGeneratedText.value.startsWith(ban)) {
for (const ban of banStart) {
if (currentGeneratedText.value.startsWith(ban)) {
currentGeneratedText.value = currentGeneratedText.value.slice(1)
}
}
@@ -283,17 +283,23 @@ const cleanupResources = () => {
// 释放LLM实例资源
if (llmInstance.value) {
try {
// 调用模型实例的清理方法(如果有)
if (typeof llmInstance.value.close === 'function') {
llmInstance.value.close();
const releaseResources = async () => {
try {
// 调用模型实例的清理方法(如果有)
if (typeof llmInstance.value.close === 'function') {
llmInstance.value.close();
}
// 置空实例引用
llmInstance.value = null;
console.log('组件卸载:已释放模型资源');
} catch (error) {
console.error('释放模型资源时出错:', error);
setTimeout(() => {
releaseResources();
}, 1000);
}
// 置空实例引用
llmInstance.value = null;
console.log('组件卸载:已释放模型资源');
} catch (error) {
console.error('释放模型资源时出错:', error);
}
releaseResources();
}
};
@@ -321,7 +327,6 @@ watch(() => currentFiles.value, () => {
height: 100%;
width: 100%;
background-color: #f5f5f5;
border-radius: 8px;
overflow: hidden;
}
@@ -358,7 +363,7 @@ watch(() => currentFiles.value, () => {
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.progress-bar {
+1 -1
View File
@@ -18,7 +18,7 @@ const pluginConfig: registerComponentOption = {
return false
}
for (let i of vFiles) {
for (let s of ['model.bin']) {
for (let s of ['model.bin', 'model.task']) {
if (i.name.endsWith(s)) {
return true
}
+51
View File
@@ -0,0 +1,51 @@
import { registerComponentOption } from '@/class/PluginCenter'
import VirtualDirectory, { VirtualFile } from '@/class/VirtualDirectory'
import { Analytics } from '@vicons/ionicons5'
const pluginConfig: registerComponentOption = {
key: 'onnx_demo_emotion',
name: 'ONNX DEMO EMOTION',
icon: Analytics,
getComponent: async () =>
(await import('@/plugins/onnx_demo_emotion/onnx_demo.vue')).default,
filter: (_vDirs: VirtualDirectory[], vFiles: VirtualFile[]) => {
if (vFiles.length === 0) {
return false
}
// 检查是否存在.onnx文件
let hasOnnxFile = false
// 检查是否存在图片文件
let hasImageFile = false
const imageSuffixList = [
'xbm', 'tif', 'pjp', 'svgz', 'jpg', 'jpeg', 'ico', 'tiff', 'gif',
'svg', 'jfif', 'webp', 'png', 'bmp', 'pjpeg', 'avif'
]
for (let vf of vFiles) {
if (vf.name.endsWith('.onnx')) {
hasOnnxFile = true
return true
}
for (let end of imageSuffixList) {
if (vf.name.endsWith(end)) {
hasImageFile = true
break
}
}
if (hasOnnxFile && hasImageFile) {
return true
}
}
return false
}
}
export default pluginConfig
import { PluginExportType } from '@/types/type'
export const type = PluginExportType.component
+318
View File
@@ -0,0 +1,318 @@
<template>
<miao-drop-handler @on-virtual-files="handleDrop">
<miao-message-provider ref="miaoMessageRef">
<div class="onnx-demo" ref="rootRef">
<n-card title="ONNX模型演示" class="onnx-demo-card">
<n-grid :cols="2" :x-gap="12">
<n-grid-item>
<div class="onnx-demo-section">
<h3>输入图像</h3>
<div v-if="inputImage" class="onnx-demo-image-container">
<img :src="inputImage.url" class="onnx-demo-image" />
<p>{{ inputImage.name }}</p>
</div>
<n-empty v-else description="无图像" />
</div>
</n-grid-item>
<n-grid-item>
<div class="onnx-demo-section">
<h3>ONNX模型</h3>
<div v-if="onnxModel" class="onnx-demo-model-info">
<p>模型: {{ onnxModel.name }}</p>
<p>大小: {{ formatFileSize(onnxModel.size) }}</p>
</div>
<n-empty v-else description="无模型" />
</div>
</n-grid-item>
</n-grid>
<!-- <div class="onnx-demo-control">
<n-button type="primary" @click="processImage" :disabled="!inputImage || !onnxModel || isProcessing"
:loading="isProcessing">
处理图像
</n-button>
</div> -->
<div class="onnx-demo-result" v-if="modelOutput">
<h3>模型输出:</h3>
<n-card embedded>
<pre>{{ modelOutput }}</pre>
</n-card>
</div>
</n-card>
</div>
</miao-message-provider>
</miao-drop-handler>
</template>
<script setup lang="ts">
import miaoDropHandler from '@/components/miaoDropHandler.vue'
import miaoMessageProvider from '@/components/miaoAlertTipProvider.vue'
import { VirtualFile } from '@/class/VirtualDirectory'
import VirtualDirectory from '@/class/VirtualDirectory'
import { onMounted, ref, computed, watch } from 'vue'
// @ts-ignore
let ort: any;
const currentFiles = defineModel<VirtualFile[]>('currentFiles', {
required: true
})
const currentDirectories = defineModel<VirtualDirectory[]>(
'currentDirectories',
{
required: true
}
)
const miaoMessageRef = ref<InstanceType<typeof miaoMessageProvider>>()
const rootRef = ref()
const isProcessing = ref(false)
const modelOutput = ref<string | null>(null)
// 获取第一个图像文件
const inputImage = computed(() => {
const imageSuffixList = [
'xbm', 'tif', 'pjp', 'svgz', 'jpg', 'jpeg', 'ico', 'tiff', 'gif',
'svg', 'jfif', 'webp', 'png', 'bmp', 'pjpeg', 'avif'
]
for (const file of currentFiles.value) {
for (const suffix of imageSuffixList) {
if (file.name.toLowerCase().endsWith(suffix)) {
return file
}
}
}
return null
})
// 获取ONNX模型文件
const onnxModel = computed(() => {
for (const file of currentFiles.value) {
if (file.name.toLowerCase().endsWith('.onnx')) {
return file
}
}
return null
})
const handleDrop = (vFiles: VirtualFile[]) => {
const imageSuffixList = [
'xbm', 'tif', 'pjp', 'svgz', 'jpg', 'jpeg', 'ico', 'tiff', 'gif',
'svg', 'jfif', 'webp', 'png', 'bmp', 'pjpeg', 'avif'
]
let imgFile: VirtualFile | null = null
out: for (const file of vFiles) {
for (const suffix of imageSuffixList) {
if (file.name.toLowerCase().endsWith(suffix)) {
imgFile = file
break out
}
}
}
if (imgFile) {
currentFiles.value.unshift(imgFile)
}
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) {
return `${size} B`
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KB`
} else {
return `${(size / (1024 * 1024)).toFixed(2)} MB`
}
}
const resizeImage = async (img: ImageBitmap, width: number, height: number): Promise<ImageBitmap> => {
const canvas = new OffscreenCanvas(width, height)
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx?.drawImage(img, 0, 0, width, height)
return await createImageBitmap(canvas)
}
// 处理图像
const processImage = async () => {
if (!inputImage.value || !onnxModel.value) {
miaoMessageRef.value?.alertTip('请选择图像和ONNX模型文件', { type: 'error', timeout: 2000 })
return
}
isProcessing.value = true
modelOutput.value = null
try {
// 显示处理信息
const alertUpdate = miaoMessageRef.value!.alertTip('正在加载ONNX模型...', { type: 'info', timeout: 2000 })
// 1. 加载模型
const modelResponse = await fetch(onnxModel.value.url)
const modelBuffer = await modelResponse.arrayBuffer()
alertUpdate('正在创建推理会话...')
// 创建ONNX会话
const session = await ort.InferenceSession.create(modelBuffer)
// 2. 加载和预处理图像
alertUpdate('正在加载图像...')
const imgResponse = await fetch(inputImage.value.url)
const imgBlob = await imgResponse.blob()
// 将图像转换为适合模型的格式
alertUpdate('正在预处理图像...')
const size = 64
const img = await createImageBitmap(imgBlob)
const data = await resizeImage(img, size, size)
const canvas = new OffscreenCanvas(size, size)
const ctx = canvas.getContext('2d') as unknown as CanvasRenderingContext2D
ctx.drawImage(data, 0, 0, size, size)
const imageData = ctx.getImageData(0, 0, size, size).data
const greyScale = [];
for (let i = 0; i < imageData.length; i += 4) {
greyScale.push(
(imageData[i] * 0.299 + imageData[i + 1] * 0.587 + imageData[i + 2] * 0.114 - 127.5) /
127.5
);
}
const inputTensor = new ort.Tensor('float32', new Float32Array(greyScale), [1, 1, size, size])
alertUpdate('执行模型推理...')
// 3. 运行模型推理
const outputMap = await session.run({
// 这里的键名应该根据实际模型的输入名称进行调整
Input2505: inputTensor
})
// 4. 处理模型输出
alertUpdate('处理模型输出结果...')
// 获取输出数据(假设输出张量名为"output",可能需要根据实际模型调整)
const outputTensor = Object.values(outputMap)[0]
// 将输出格式化为可读内容
const formattedOutput = JSON.stringify(
{
// shape: outputTensor.dims,
data: [...[...(outputTensor as { data: Float32Array }).data].entries()].sort((a, b) => b[1] - a[1]).map(([index, value]) => ({ index, value }))
},
null,
2
)
// 设置结果
modelOutput.value = formattedOutput
alertUpdate('处理完成!', { type: 'success' })
} catch (error) {
console.error('ONNX处理错误:', error)
miaoMessageRef.value?.alertTip(`处理出错`, { type: 'error', timeout: 2000 })
modelOutput.value = `错误: ${error}`
} finally {
isProcessing.value = false
}
}
onMounted(async () => {
console.log('currentDirectories.value[0].url', currentDirectories.value[0]?.url)
// 如果传入了onnxruntime-web,则使用传入的,否则从jsdelivr加载
if (currentDirectories.value && currentDirectories.value.length > 0 && currentDirectories.value[0].url) {
const ortModule = await import(/* @vite-ignore */ `${currentDirectories.value[0].url}dist/ort.all.min.mjs`);
ort = ortModule;
console.log('ort', ort)
} else {
// 从jsdelivr加载
// @ts-ignore
const ortModule = await import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.all.min.js');
ort = ortModule;
}
// 初始化模型
await initModel();
})
// 初始化模型函数
const initModel = async () => {
// 初始化时检查是否有适合的文件
if (inputImage.value && onnxModel.value) {
miaoMessageRef.value?.alertTip('已检测到图像和ONNX模型,可以进行处理', { type: 'info', timeout: 2000 })
}
watch(() => inputImage.value, () => {
processImage()
}, {
immediate: true
})
}
</script>
<style scoped>
.onnx-demo {
width: 100%;
height: 100%;
padding: 16px;
overflow: auto;
}
.onnx-demo-card {
margin-bottom: 16px;
}
.onnx-demo-section {
padding: 8px;
text-align: center;
border: 1px dashed #ccc;
border-radius: 4px;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
}
.onnx-demo-image-container {
display: flex;
flex-direction: column;
align-items: center;
}
.onnx-demo-image {
max-width: 100%;
max-height: 180px;
object-fit: contain;
}
.onnx-demo-model-info {
text-align: left;
width: 100%;
}
.onnx-demo-control {
display: flex;
justify-content: center;
margin: 16px 0;
}
.onnx-demo-result {
margin-top: 16px;
border-top: 1px solid #eee;
padding-top: 16px;
}
.onnx-demo-result pre {
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow: auto;
background-color: #f5f5f5;
padding: 8px;
border-radius: 4px;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import { registerComponentOption } from '@/class/PluginCenter'
import VirtualDirectory, { VirtualFile } from '@/class/VirtualDirectory'
import { Analytics } from '@vicons/ionicons5'
const pluginConfig: registerComponentOption = {
key: 'onnx_demo_mnist',
name: 'ONNX DEMO MNIST',
icon: Analytics,
getComponent: async () =>
// @ts-ignore
(await import('@/plugins/onnx_demo_mnist/onnx_demo_mnist.vue')).default,
filter: (_vDirs: VirtualDirectory[], vFiles: VirtualFile[]) => {
if (vFiles.length === 0) {
return false
}
// 检查是否存在.onnx文件
for (let vf of vFiles) {
if (vf.name.endsWith('.onnx')) {
return true
}
}
return false
}
}
export default pluginConfig
import { PluginExportType } from '@/types/type'
export const type = PluginExportType.component
@@ -0,0 +1,536 @@
<template>
<miao-drop-handler @on-virtual-files="handleDrop">
<miao-message-provider ref="miaoMessageRef">
<div class="mnist-demo" ref="rootRef">
<n-card title="MNIST手写数字识别" class="mnist-demo-card">
<n-grid :cols="2" :x-gap="12">
<n-grid-item>
<div class="mnist-demo-section">
<h3>手写数字画板 (28×28像素)</h3>
<div class="canvas-container">
<canvas ref="drawingCanvasRef" width="28" height="28" class="drawing-canvas"
@mousedown="startDrawing" @mousemove="draw" @mouseup="stopDrawing"
@mouseleave="stopDrawing" @touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove" @touchend.prevent="stopDrawing"></canvas>
<div class="canvas-controls">
<n-button @click="clearCanvas">清除</n-button>
<n-button type="primary" @click="recognizeDigit" :disabled="!onnxModel || isProcessing"
:loading="isProcessing">
识别
</n-button>
</div>
</div>
</div>
</n-grid-item>
<n-grid-item>
<div class="mnist-demo-section">
<h3>ONNX模型</h3>
<div v-if="onnxModel" class="mnist-demo-model-info">
<p>模型: {{ onnxModel.name }}</p>
<p>大小: {{ formatFileSize(onnxModel.size) }}</p>
</div>
<n-empty v-else description="无模型" />
<div class="mnist-demo-result" v-if="modelOutput">
<h3>识别结果:</h3>
<div class="result-container">
<div class="result-digit">{{ recognizedDigit }}</div>
<!-- <n-progress type="line" :percentage="confidence" :indicator-placement="'inside'" :color="resultColor" /> -->
<div class="confidence-text">置信度: {{ (confidence).toFixed(2) }}%</div>
</div>
</div>
</div>
</n-grid-item>
</n-grid>
</n-card>
</div>
</miao-message-provider>
</miao-drop-handler>
</template>
<script setup lang="ts">
import miaoDropHandler from '@/components/miaoDropHandler.vue'
import miaoMessageProvider from '@/components/miaoAlertTipProvider.vue'
import { VirtualFile } from '@/class/VirtualDirectory'
import VirtualDirectory from '@/class/VirtualDirectory'
import { onMounted, ref, computed } from 'vue'
// @ts-ignore
let ort: any;
const currentFiles = defineModel<VirtualFile[]>('currentFiles', {
required: true
})
const currentDirectories = defineModel<VirtualDirectory[]>(
'currentDirectories',
{
required: true
}
)
// 画板常量
const GRID_SIZE = 28 // MNIST 模型的输入是 28x28
// const DISPLAY_SCALE = 10 // 显示缩放比例,用于CSS缩放
const miaoMessageRef = ref<InstanceType<typeof miaoMessageProvider>>()
const rootRef = ref()
const drawingCanvasRef = ref<HTMLCanvasElement | null>(null)
const isProcessing = ref(false)
const modelOutput = ref<number[] | null>(null)
const recognizedDigit = ref<number | null>(null)
const confidence = ref<number>(0)
const resultColor = ref('#18a058')
// 绘图相关状态
const isDrawing = ref(false)
const lastPos = ref<{ x: number, y: number } | null>(null)
const gridData = ref<number[][]>(Array(GRID_SIZE).fill(0).map(() => Array(GRID_SIZE).fill(0)))
// 获取ONNX模型文件
const onnxModel = computed(() => {
for (const file of currentFiles.value) {
if (file.name.toLowerCase().endsWith('.onnx')) {
return file
}
}
return null
})
// 获取Canvas上下文
const getContext = (): CanvasRenderingContext2D | null => {
const canvas = drawingCanvasRef.value
if (!canvas) return null
const ctx = canvas.getContext('2d')
if (!ctx) return null
return ctx
}
// 将显示坐标转换为Canvas像素坐标
const convertCoordsToCanvas = (displayX: number, displayY: number): { x: number, y: number } => {
const canvas = drawingCanvasRef.value
if (!canvas) return { x: 0, y: 0 }
// 获取Canvas的CSS尺寸
const rect = canvas.getBoundingClientRect()
// 计算缩放比例
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
// 将显示坐标转换为Canvas坐标
const canvasX = (displayX - rect.left) * scaleX
const canvasY = (displayY - rect.top) * scaleY
// 返回整数坐标(因为我们是28x28的像素级别操作)
return {
x: Math.floor(canvasX),
y: Math.floor(canvasY)
}
}
// 画板绘图相关函数
const clearCanvas = () => {
const ctx = getContext()
if (!ctx) return
// 清空Canvas
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, GRID_SIZE, GRID_SIZE)
// 清空数据
gridData.value = Array(GRID_SIZE).fill(0).map(() => Array(GRID_SIZE).fill(0))
// 清除结果
modelOutput.value = null
recognizedDigit.value = null
confidence.value = 0
}
// 开始绘制
const startDrawing = (e: MouseEvent) => {
isDrawing.value = true
const { x, y } = convertCoordsToCanvas(e.clientX, e.clientY)
lastPos.value = { x, y }
drawPixel(x, y)
}
// 绘制
const draw = (e: MouseEvent) => {
if (!isDrawing.value || !lastPos.value) return
const { x, y } = convertCoordsToCanvas(e.clientX, e.clientY)
if (x === lastPos.value.x && y === lastPos.value.y) return // 避免在同一像素重复绘制
// 绘制当前像素
drawPixel(x, y)
// 绘制连续线条(Bresenham算法)
const { x: lastX, y: lastY } = lastPos.value
drawLine(lastX, lastY, x, y)
// 更新最后位置
lastPos.value = { x, y }
}
// 停止绘制
const stopDrawing = () => {
isDrawing.value = false
lastPos.value = null
// 更新到gridData(因为我们在绘制时已经更新了,所以这里不需要额外操作)
// 如果有ONNX模型且当前没有在推理中,则自动进行推理
// if (onnxModel.value && !isProcessing.value) {
// recognizeDigit()
// }
}
// 绘制单个像素
const drawPixel = (x: number, y: number) => {
if (x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE) return
const ctx = getContext()
if (!ctx) return
// 绘制到Canvas
ctx.fillStyle = 'black'
ctx.fillRect(x, y, 1, 1)
// 更新数据
gridData.value[y][x] = 1
}
// 使用Bresenham算法绘制线条
const drawLine = (x0: number, y0: number, x1: number, y1: number) => {
const dx = Math.abs(x1 - x0)
const dy = Math.abs(y1 - y0)
const sx = x0 < x1 ? 1 : -1
const sy = y0 < y1 ? 1 : -1
let err = dx - dy
while (true) {
// 如果是终点,结束循环
if (x0 === x1 && y0 === y1) break
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
x0 += sx
}
if (e2 < dx) {
err += dx
y0 += sy
}
drawPixel(x0, y0)
}
}
// 处理触摸事件
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return
const touch = e.touches[0]
const { x, y } = convertCoordsToCanvas(touch.clientX, touch.clientY)
isDrawing.value = true
lastPos.value = { x, y }
drawPixel(x, y)
}
const handleTouchMove = (e: TouchEvent) => {
if (!isDrawing.value || !lastPos.value) return
if (e.touches.length !== 1) return
const touch = e.touches[0]
const { x, y } = convertCoordsToCanvas(touch.clientX, touch.clientY)
if (x === lastPos.value.x && y === lastPos.value.y) return // 避免在同一像素重复绘制
// 绘制当前像素
drawPixel(x, y)
// 绘制连续线条
const { x: lastX, y: lastY } = lastPos.value
drawLine(lastX, lastY, x, y)
// 更新最后位置
lastPos.value = { x, y }
}
// 从Canvas更新gridData(在本实现中,我们在绘制时已经同步更新了gridData,这个函数保留用于检查)
const updateGridDataFromCanvas = () => {
const ctx = getContext()
if (!ctx) return
// 从Canvas读取像素数据
const imageData = ctx.getImageData(0, 0, GRID_SIZE, GRID_SIZE)
const data = imageData.data
// 更新gridData
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const pixelIndex = (y * GRID_SIZE + x) * 4
const r = data[pixelIndex]
const g = data[pixelIndex + 1]
const b = data[pixelIndex + 2]
// 简单阈值处理,非白即黑
gridData.value[y][x] = (r + g + b < 600) ? 1 : 0
}
}
}
// 处理文件拖放
const handleDrop = (vFiles: VirtualFile[]) => {
for (const file of vFiles) {
if (file.name.toLowerCase().endsWith('.onnx')) {
currentFiles.value.unshift(file)
miaoMessageRef.value?.alertTip('已添加ONNX模型文件', { type: 'success', timeout: 2000 })
return
}
}
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) {
return `${size} B`
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(2)} KB`
} else {
return `${(size / (1024 * 1024)).toFixed(2)} MB`
}
}
// 将grid数据预处理为MNIST输入格式
const preprocessGridData = (): Float32Array => {
// 转换为MNIST模型期望的格式 (1x1x28x28)
const inputData = new Float32Array(GRID_SIZE * GRID_SIZE)
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
// 计算一维索引
const index = row * GRID_SIZE + col
// 存储像素值(MNIST期望格式为:黑色笔画对应较高的值,白色背景对应较低的值)
inputData[index] = gridData.value[row][col]
}
}
return inputData
}
// 识别手写数字
const recognizeDigit = async () => {
if (!onnxModel.value) {
miaoMessageRef.value?.alertTip('请先选择ONNX模型文件', { type: 'error', timeout: 2000 })
return
}
// 在识别前检查并更新gridData
updateGridDataFromCanvas()
isProcessing.value = true
modelOutput.value = null
recognizedDigit.value = null
confidence.value = 0
try {
// 显示处理信息
const alertUpdate = miaoMessageRef.value!.alertTip('正在加载ONNX模型...', { type: 'info', timeout: 2000 })
// 1. 加载模型
const modelResponse = await fetch(onnxModel.value.url)
const modelBuffer = await modelResponse.arrayBuffer()
alertUpdate('正在创建推理会话...')
// 创建ONNX会话
const session = await ort.InferenceSession.create(modelBuffer)
// 2. 预处理画布数据
alertUpdate('正在预处理图像...')
const inputData = preprocessGridData()
// 创建输入张量
// MNIST模型通常期望形状为[1, 1, 28, 28]的输入
const inputTensor = new ort.Tensor('float32', inputData, [1, 1, 28, 28])
alertUpdate('执行模型推理...')
// 3. 运行模型推理 - 使用"Input3"作为输入名称
const outputs = await session.run({
// 使用正确的输入名称
Input3: inputTensor
})
// 4. 处理模型输出
alertUpdate('处理模型输出结果...')
// 获取输出数据
const outputTensor = Object.values(outputs)[0]
const outputData = [...(outputTensor as { data: Float32Array }).data]
// 设置结果
modelOutput.value = outputData
// 找出最可能的数字(最大概率对应的索引)
let maxIndex = 0
let maxProb = outputData[0]
for (let i = 1; i < outputData.length; i++) {
if (outputData[i] > maxProb) {
maxProb = outputData[i]
maxIndex = i
}
}
recognizedDigit.value = maxIndex
// 计算置信度百分比
const softmaxSum = outputData.reduce((sum, val) => sum + Math.exp(val), 0)
const probability = Math.exp(maxProb) / softmaxSum
confidence.value = probability * 100
// 设置结果颜色
if (confidence.value > 90) {
resultColor.value = '#18a058' // 绿色
} else if (confidence.value > 70) {
resultColor.value = '#2080f0' // 蓝色
} else if (confidence.value > 50) {
resultColor.value = '#f0a020' // 橙色
} else {
resultColor.value = '#d03050' // 红色
}
alertUpdate('识别完成!', { type: 'success' })
} catch (error) {
console.error('ONNX处理错误:', error)
miaoMessageRef.value?.alertTip(`处理出错: ${error}`, { type: 'error', timeout: 2000 })
} finally {
isProcessing.value = false
}
}
onMounted(async () => {
// 如果传入了onnxruntime-web,则使用传入的,否则从jsdelivr加载
if (currentDirectories.value && currentDirectories.value.length > 0 && currentDirectories.value[0]?.url) {
const ortModule = await import(/* @vite-ignore */ `${currentDirectories.value[0]?.url}/dist/ort.all.min.mjs`);
ort = ortModule;
} else {
// 从jsdelivr加载
// @ts-ignore
const ortModule = await import(/* @vite-ignore */ 'https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.all.min.js');
ort = ortModule;
}
// 初始化绘图区域
if (drawingCanvasRef.value) {
initCanvas();
}
})
// 添加初始化画布函数
const initCanvas = () => {
// 初始化Canvas
const ctx = getContext()
if (ctx) {
// 设置白色背景
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, GRID_SIZE, GRID_SIZE)
// 设置抗锯齿(关闭,保证像素对齐)
ctx.imageSmoothingEnabled = false
}
// 检查是否有ONNX模型
if (onnxModel.value) {
miaoMessageRef.value?.alertTip('已检测到ONNX模型文件,您可以绘制数字进行识别', { type: 'info', timeout: 2000 })
} else {
miaoMessageRef.value?.alertTip('请添加MNIST模型(.onnx)文件', { type: 'info', timeout: 2000 })
}
}
</script>
<style scoped>
.mnist-demo {
width: 100%;
height: 100%;
padding: 16px;
overflow: auto;
}
.mnist-demo-card {
margin-bottom: 16px;
}
.mnist-demo-section {
padding: 8px;
text-align: center;
border: 1px dashed #ccc;
border-radius: 4px;
min-height: 320px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.canvas-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 8px;
}
.drawing-canvas {
width: 280px;
height: 280px;
border: 1px solid #ccc;
touch-action: none;
image-rendering: pixelated;
/* 像素化渲染 */
image-rendering: crisp-edges;
/* 确保边缘清晰 */
background-color: white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
}
.canvas-controls {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 16px;
}
.mnist-demo-model-info {
text-align: left;
width: 100%;
margin-bottom: 20px;
}
.mnist-demo-result {
margin-top: 16px;
border-top: 1px solid #eee;
padding-top: 16px;
width: 100%;
}
.result-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 16px;
}
.result-digit {
font-size: 64px;
font-weight: bold;
line-height: 1;
margin-bottom: 16px;
}
.confidence-text {
margin-top: 8px;
font-size: 14px;
color: #666;
}
</style>
+4 -3
View File
@@ -1,4 +1,5 @@
import VirtualDirectory, { Tree, VirtualFile } from '@/class/VirtualDirectory'
import { Tree } from '@/class/VirtualDirectory'
import { VirtualRemoteDirectory, VirtualRemoteFile } from '@/class/VirtualRemoteDirectory'
/**
* 通过VirtualDirectory的.tree重建文件夹树
@@ -6,8 +7,8 @@ import VirtualDirectory, { Tree, VirtualFile } from '@/class/VirtualDirectory'
* @returns {any}
*/
const buildFromTree = (data: Tree) => {
const root = new VirtualDirectory(data)
root.files = data.files?.map((v) => new VirtualFile(v, root))
const root = new VirtualRemoteDirectory(data)
root.files = data.files?.map((v) => new VirtualRemoteFile(v, root))
root.directories = data.directories?.map((v) => buildFromTree(v))
return root
}