update
This commit is contained in:
1 parent
286dd8e386
commit
01085a3ba5
12 files changed
+1133
-208
No files matched your search
+5
-1
@@ -35,4 +35,8 @@ auto-imports.d.ts
|
||||
|
||||
components.d.ts
|
||||
|
||||
src/plugins/onnx_demo_*
|
||||
# src/plugins/onnx_demo_*
|
||||
|
||||
public/test
|
||||
|
||||
doc
|
||||
@@ -25,122 +25,4 @@
|
||||
|
||||
## 碎碎念:
|
||||
|
||||
写的真潦草
|
||||
|
||||
## 让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() │
|
||||
│ (获取根目录实例钩子) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
写的真潦草
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
// 添加新文件和目录,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 oldFiles = [...this.files];
|
||||
const newFilesList: VirtualRemoteFile[] = [];
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,62 +96,62 @@ 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引擎...');
|
||||
|
||||
|
||||
// 模拟下载进度
|
||||
const simulateDownloadProgress = () => {
|
||||
const totalTime = 2000; // 2秒
|
||||
const interval = 100; // 每100毫秒更新一次
|
||||
const steps = totalTime / interval;
|
||||
const incrementPerStep = 15 / steps; // 从5%到20%
|
||||
|
||||
|
||||
let currentStep = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (currentStep >= steps) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
loadingProgress.value = Math.min(20, 5 + currentStep * incrementPerStep);
|
||||
currentStep++;
|
||||
}, interval);
|
||||
};
|
||||
|
||||
|
||||
simulateDownloadProgress();
|
||||
|
||||
// 初始化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"
|
||||
);
|
||||
|
||||
|
||||
// 第二阶段:文件解析器加载完成
|
||||
updateLoadingProgress(40, '正在加载模型文件...');
|
||||
|
||||
|
||||
// 模拟模型文件加载进度
|
||||
const simulateModelLoadingProgress = () => {
|
||||
const totalTime = 3000; // 3秒
|
||||
const interval = 100; // 每100毫秒更新一次
|
||||
const steps = totalTime / interval;
|
||||
const incrementPerStep = 45 / steps; // 从40%到85%
|
||||
|
||||
|
||||
let currentStep = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (currentStep >= steps) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
loadingProgress.value = Math.min(85, 40 + currentStep * incrementPerStep);
|
||||
currentStep++;
|
||||
}, interval);
|
||||
};
|
||||
|
||||
|
||||
simulateModelLoadingProgress();
|
||||
|
||||
|
||||
// 创建LLM推理实例
|
||||
llmInstance.value = await LlmInference.createFromOptions(genai, {
|
||||
baseOptions: {
|
||||
@@ -160,16 +160,16 @@ const initializeModel = async () => {
|
||||
maxTokens: 1024,
|
||||
temperature: 0.7
|
||||
});
|
||||
|
||||
|
||||
// 第三阶段:模型加载完成
|
||||
updateLoadingProgress(90, '初始化对话...');
|
||||
|
||||
|
||||
// 短暂延迟以显示最终阶段
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
|
||||
// 加载完成
|
||||
updateLoadingProgress(100, '加载完成!');
|
||||
|
||||
|
||||
// 短暂延迟以显示100%完成状态
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
@@ -221,10 +221,10 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部以显示最新内容
|
||||
@@ -280,20 +280,26 @@ const cleanupResources = () => {
|
||||
generatingMessage.value = false;
|
||||
console.log('组件卸载:已停止正在进行的文本生成');
|
||||
}
|
||||
|
||||
|
||||
// 释放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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user