feat(bot): ✨ add preview 3D models

modify bot type display
This commit is contained in:
Dawn
2025-10-20 03:52:26 +08:00
parent d05541e6c7
commit 8015bf925c
12 changed files with 895 additions and 67 deletions
+2
View File
@@ -124,6 +124,7 @@
"seemly": "^0.3.10",
"tauri-plugin-mic-recorder-api": "^2.0.0",
"tauri-plugin-safe-area-insets": "^0.1.0",
"three": "^0.180.0",
"tlbs-map-vue": "^1.3.1",
"vant": "^4.9.21",
"vue": "^3.5.22",
@@ -145,6 +146,7 @@
"@types/crypto-js": "^4.2.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.6.2",
"@types/three": "^0.180.0",
"@unocss/preset-wind3": "^66.5.2",
"@unocss/reset": "^66.5.2",
"@unocss/transformer-directives": "^66.5.2",
Generated Vendored
+54
View File
@@ -143,6 +143,9 @@ importers:
tauri-plugin-safe-area-insets:
specifier: ^0.1.0
version: 0.1.0
three:
specifier: ^0.180.0
version: 0.180.0
tlbs-map-vue:
specifier: ^1.3.1
version: 1.3.1(vue@3.5.22(typescript@5.9.3))
@@ -201,6 +204,9 @@ importers:
'@types/node':
specifier: ^24.6.2
version: 24.6.2
'@types/three':
specifier: ^0.180.0
version: 0.180.0
'@unocss/preset-wind3':
specifier: ^66.5.2
version: 66.5.2
@@ -620,6 +626,9 @@ packages:
peerDependencies:
vue: ^3.0.11
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@emnapi/runtime@1.3.1':
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
@@ -1772,6 +1781,9 @@ packages:
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
@@ -1814,12 +1826,21 @@ packages:
'@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.180.0':
resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -2081,6 +2102,9 @@ packages:
peerDependencies:
vue: ^3.5.0
'@webgpu/types@0.1.66':
resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true
@@ -3506,6 +3530,9 @@ packages:
merge@2.1.1:
resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==}
meshoptimizer@0.22.0:
resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -4275,6 +4302,9 @@ packages:
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
engines: {node: '>=8'}
three@0.180.0:
resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==}
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@@ -5164,6 +5194,8 @@ snapshots:
dependencies:
vue: 3.5.22(typescript@5.9.3)
'@dimforge/rapier3d-compat@0.12.0': {}
'@emnapi/runtime@1.3.1':
dependencies:
tslib: 2.8.1
@@ -6051,6 +6083,8 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@tweenjs/tween.js@23.1.3': {}
'@types/chai@5.2.2':
dependencies:
'@types/deep-eql': 4.0.2
@@ -6089,11 +6123,25 @@ snapshots:
'@types/semver@7.5.8': {}
'@types/stats.js@0.17.4': {}
'@types/three@0.180.0':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
'@webgpu/types': 0.1.66
fflate: 0.8.2
meshoptimizer: 0.22.0
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.21': {}
'@types/webxr@0.5.24': {}
'@types/whatwg-mimetype@3.0.2': {}
'@unocss/config@66.5.2':
@@ -6461,6 +6509,8 @@ snapshots:
dependencies:
vue: 3.5.22(typescript@5.9.3)
'@webgpu/types@0.1.66': {}
JSONStream@1.3.5:
dependencies:
jsonparse: 1.3.1
@@ -7890,6 +7940,8 @@ snapshots:
merge@2.1.1: {}
meshoptimizer@0.22.0: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -8711,6 +8763,8 @@ snapshots:
text-extensions@2.4.0: {}
three@0.180.0: {}
through@2.3.8: {}
time-span@5.1.0:
+51
View File
@@ -0,0 +1,51 @@
// TODO 需要测试
const MODEL_VERSION = '20251020';
const CACHE_NAME = `hula-model-cache-${MODEL_VERSION}`;
const MODEL_ORIGIN = 'https://cdn.hulaspark.com';
const MODEL_PATH = '/models/hula.glb';
const MODEL_URL = `${MODEL_ORIGIN}${MODEL_PATH}?v=${MODEL_VERSION}`;
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.add(MODEL_URL)).catch(() => Promise.resolve())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith('hula-model-cache-') && key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
});
self.addEventListener('fetch', (event) => {
const requestUrl = new URL(event.request.url);
if (requestUrl.origin === MODEL_ORIGIN && requestUrl.pathname === MODEL_PATH) {
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(MODEL_URL);
if (cached) {
fetch(event.request)
.then((response) => {
if (response && response.ok) {
cache.put(MODEL_URL, response.clone());
}
})
.catch(() => void 0);
return cached;
}
return fetch(event.request).then((response) => {
if (response && response.ok) {
cache.put(MODEL_URL, response.clone());
}
return response;
});
})
);
}
});
+4
View File
@@ -15,6 +15,7 @@
5FA07CA316D84B7C6157AD98 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C5561AC4EF6A7D22351F4123 /* Metal.framework */; };
66C0485D21D30EE3FC68535D /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43A3F90CF52FE87749F6AF66 /* WebKit.framework */; };
725ABB36A3E4DBADE9B85F1A /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = BEB6A0287B0C05B54BB18D76 /* main.mm */; };
8B9E3BA9E9A719E6B5F24BA7 /* KeyboardAccessory.mm in Sources */ = {isa = PBXBuildFile; fileRef = 6DE0FD4D0E352521D4B95B49 /* KeyboardAccessory.mm */; };
C6E3EE1EC5041769CD8C63BC /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51F03346AEBB6EB6B6949B1F /* MetalKit.framework */; };
DFD77F9BDCD981184134D70B /* assets in Resources */ = {isa = PBXBuildFile; fileRef = E0D58092E5EAD80CE1062F64 /* assets */; };
E4594A304B63C1F809276D32 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0B6EF43A950A8BAEED368AF5 /* Assets.xcassets */; };
@@ -35,6 +36,7 @@
5822DACEC671151891D1FD54 /* init.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = init.rs; sourceTree = "<group>"; };
582937AEB5D7F35B81C5A220 /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
5E348B57006C5877940CA019 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
6DE0FD4D0E352521D4B95B49 /* KeyboardAccessory.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = KeyboardAccessory.mm; sourceTree = "<group>"; };
658A253702F290FC4BBF60D5 /* mod.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = mod.rs; sourceTree = "<group>"; };
76DB59D3790398E1FDB32D5A /* mod.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = mod.rs; sourceTree = "<group>"; };
8EA08F12BCB04981BCB86BEF /* common_cmd.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = common_cmd.rs; sourceTree = "<group>"; };
@@ -193,6 +195,7 @@
isa = PBXGroup;
children = (
BEB6A0287B0C05B54BB18D76 /* main.mm */,
6DE0FD4D0E352521D4B95B49 /* KeyboardAccessory.mm */,
4927FA144B0F8AA1B601E34F /* bindings */,
);
path = hula;
@@ -291,6 +294,7 @@
buildActionMask = 2147483647;
files = (
725ABB36A3E4DBADE9B85F1A /* main.mm in Sources */,
8B9E3BA9E9A719E6B5F24BA7 /* KeyboardAccessory.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
File diff suppressed because one or more lines are too long
+161 -47
View File
@@ -3,53 +3,57 @@
<!-- 顶部工具栏 -->
<div class="language-switcher">
<!-- 返回按钮 -->
<div v-if="canGoBack" class="back-btn" @click="goBack">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
<div v-if="canGoBack" class="back-btn flex-shrink-0" @click="goBack">
<svg class="size-16px rotate-180"><use href="#right"></use></svg>
返回
</div>
<!-- 语言切换器 (仅在查看 README 时显示) -->
<template v-if="!isViewingLink">
<div :class="['lang-btn', { active: currentLang === 'zh' }]" @click="switchLanguage('zh')">中文</div>
<div :class="['lang-btn', { active: currentLang === 'en' }]" @click="switchLanguage('en')">English</div>
</template>
<div v-if="!isViewingLink" class="flex-y-center w-full justify-between">
<div class="flex-center gap-12px">
<div :class="['lang-btn', { active: currentLang === 'zh' }]" @click="switchLanguage('zh')">中文</div>
<div :class="['lang-btn', { active: currentLang === 'en' }]" @click="switchLanguage('en')">English</div>
</div>
<div class="flex-center">
<n-button
v-if="isAssistantView && canImportLocalModel"
size="small"
strong
secondary
class="import-btn"
@click="openLocalModel">
导入模型
</n-button>
<n-badge class="mr-14px" value="Beta" :color="'var(--bate-color)'">
<div :class="['assistant-btn', { active: isAssistantView }]" @click="showAssistant()">3D预览</div>
</n-badge>
</div>
</div>
<!-- 当前页面标题和操作按钮 -->
<div v-if="isViewingLink" class="page-title">{{ currentUrl }}</div>
<div v-if="isViewingLink" class="open-in-browser-btn" @click="openInBrowser">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
<svg class="size-16px"><use href="#share"></use></svg>
在浏览器中打开
</div>
</div>
<div class="bot-content">
<n-loading-bar-provider ref="loadingBarRef" :to="false" :container-style="loadingBarContainerStyle">
<!-- HuLa 小管家 3D 模型 -->
<HuLaAssistant
v-if="isAssistantView"
:active="isAssistantView"
:custom-model="customModelPath"
@ready="handleAssistantReady"
@error="handleAssistantError" />
<!-- Markdown 内容区域 -->
<div v-if="!isViewingLink" ref="markdownContainer" class="markdown-content" v-html="renderedMarkdown"></div>
<div
v-else-if="!isViewingLink"
ref="markdownContainer"
class="markdown-content"
v-html="renderedMarkdown"></div>
<!-- 外部链接 Tauri Webview 容器 -->
<div v-else ref="webviewContainer" class="external-webview">
@@ -71,8 +75,10 @@ import { Webview } from '@tauri-apps/api/webview'
import { getCurrentWindow, type Window as TauriWindow } from '@tauri-apps/api/window'
import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'
import type { UnlistenFn } from '@tauri-apps/api/event'
import { open } from '@tauri-apps/plugin-dialog'
import { isDesktop } from '@/utils/PlatformConstants'
import { useBotStore } from '@/stores/bot'
import HuLaAssistant from './HuLaAssistant.vue'
// 当前语言
const currentLang = ref<'zh' | 'en'>('zh')
@@ -91,12 +97,30 @@ const markdownContainer = ref<HTMLElement | null>(null)
const webviewContainer = ref<HTMLElement | null>(null)
// 视图状态描述, 用于维护“返回”栈
type ViewState = { type: 'readme' } | { type: 'markdown'; source: string } | { type: 'web'; url: string }
type ViewState =
| { type: 'readme' }
| { type: 'markdown'; source: string }
| { type: 'web'; url: string }
| { type: 'assistant' }
const cloneView = (view: ViewState): ViewState => {
if (view.type === 'readme' || view.type === 'assistant') {
return { type: view.type }
}
if (view.type === 'markdown') {
return { type: 'markdown', source: view.source }
}
return { type: 'web', url: view.url }
}
const currentView = ref<ViewState>({ type: 'readme' })
// 记录历史视图, 便于在 Markdown 与外链之间返回
const historyStack = ref<ViewState[]>([])
const canGoBack = computed(() => historyStack.value.length > 0)
const isAssistantView = computed(() => currentView.value.type === 'assistant')
const customModelPath = ref<string | null>(null)
const canImportLocalModel = isDesktop()
let assistantFallbackView: ViewState | null = null
const botStore = useBotStore()
@@ -136,14 +160,7 @@ const canEmbedWebview = computed(() => {
// 将当前视图快照压入栈, 保证后续可回退
const pushCurrentView = () => {
const view = currentView.value
if (view.type === 'readme') {
historyStack.value.push({ type: 'readme' })
} else if (view.type === 'markdown') {
historyStack.value.push({ type: 'markdown', source: view.source })
} else {
historyStack.value.push({ type: 'web', url: view.url })
}
historyStack.value.push(cloneView(currentView.value))
}
const ensureHostWindow = async () => {
@@ -199,6 +216,74 @@ const destroyExternalWebview = async () => {
}
}
let assistantShouldPopHistoryOnError = false
const handleAssistantReady = () => {
assistantFallbackView = null
assistantShouldPopHistoryOnError = false
}
const handleAssistantError = async (error: unknown) => {
console.error('加载 HuLa 小管家失败:', error)
customModelPath.value = null
if (assistantShouldPopHistoryOnError && historyStack.value.length) {
historyStack.value.pop()
}
assistantShouldPopHistoryOnError = false
const fallback = assistantFallbackView
assistantFallbackView = null
if (!fallback) return
if (fallback.type === 'readme') {
await loadReadme(false)
} else if (fallback.type === 'markdown') {
await loadMarkdownFile(fallback.source, false)
} else if (fallback.type === 'web') {
await showExternalLink(fallback.url, false)
}
}
const showAssistant = async (recordHistory = true, preserveCustomModel = false) => {
if (currentView.value.type === 'assistant') {
if (preserveCustomModel) {
await nextTick()
}
return
}
if (!preserveCustomModel) {
customModelPath.value = null
}
assistantFallbackView = cloneView(currentView.value)
assistantShouldPopHistoryOnError = recordHistory
if (recordHistory) {
pushCurrentView()
}
await destroyExternalWebview()
isViewingLink.value = false
currentUrl.value = ''
currentView.value = { type: 'assistant' }
await nextTick()
}
const openLocalModel = async () => {
try {
const selected = await open({
filters: [
{
name: '3D Models',
extensions: ['glb', 'gltf', 'vrm']
}
],
multiple: false
})
if (!selected) return
customModelPath.value = Array.isArray(selected) ? selected[0] : selected
await showAssistant(true, true)
} catch (error) {
console.error('选择本地模型失败:', error)
window.$message?.error('选择模型文件失败,请重试')
}
}
const createExternalWebview = async (url: string) => {
// 将新 Webview 附着到当前窗口, 坐标尺寸以容器为准保持贴合
const windowInstance = await ensureHostWindow()
@@ -301,15 +386,16 @@ const loadReadme = async (recordHistory = false, resetHistory = false) => {
})
// 使用 DOMPurify 进行额外的安全处理
renderedMarkdown.value = DOMPurify.sanitize(html)
// 先更新视图状态, 确保 nextTick 时容器已挂载
currentView.value = { type: 'readme' }
isViewingLink.value = false
currentUrl.value = ''
// 等待 DOM 更新后添加链接点击监听
await nextTick()
attachLinkListeners()
finishLoading()
botStore.setReadme(currentLang.value)
currentView.value = { type: 'readme' }
isViewingLink.value = false
currentUrl.value = ''
} catch (error) {
console.error('加载 README 失败:', error)
renderedMarkdown.value = '<p>加载失败,请稍后重试</p>'
@@ -337,15 +423,15 @@ const loadMarkdownFile = async (filePath: string, recordHistory = true) => {
// 显示在 markdown 视图中,而不是 iframe
isViewingLink.value = false
// 先更新视图状态, 确保 nextTick 时容器已挂载
currentView.value = { type: 'markdown', source: filePath }
currentUrl.value = ''
// 等待 DOM 更新后添加链接点击监听
await nextTick()
attachLinkListeners()
finishLoading()
botStore.setMarkdown(filePath)
currentView.value = { type: 'markdown', source: filePath }
isViewingLink.value = false
currentUrl.value = ''
} catch (error) {
console.error('加载 markdown 文件失败:', error)
renderedMarkdown.value = `<p>加载文件失败: ${filePath}</p><p>错误: ${error}</p>`
@@ -432,6 +518,8 @@ const goBack = async () => {
await loadReadme(false)
} else if (previous.type === 'markdown') {
await loadMarkdownFile(previous.source, false)
} else if (previous.type === 'assistant') {
await showAssistant(false)
} else {
await showExternalLink(previous.url, false)
}
@@ -555,6 +643,28 @@ onUnmounted(() => {
}
}
.assistant-btn {
padding: 6px 18px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-color);
background: linear-gradient(135deg, rgba(19, 152, 127, 0.32), rgba(19, 152, 127, 0.1));
transition: all 0.2s ease-in-out;
user-select: none;
-webkit-user-select: none;
&:hover {
color: #13987f;
}
&.active {
color: #ffffff;
background: linear-gradient(135deg, #13987f, #1fb39b80);
border-color: rgba(19, 152, 127, 0.4);
}
}
.lang-btn {
padding: 6px 16px;
border-radius: 6px;
@@ -578,6 +688,10 @@ onUnmounted(() => {
box-shadow: inset 0 0 0 1px #fbb16040;
}
}
.import-btn {
margin-right: 16px;
}
}
.bot-content {
@@ -0,0 +1,501 @@
<template>
<div ref="container" class="assistant-view">
<div v-if="props.active && !isReady" class="assistant-view__placeholder">
<n-progress
:percentage="loadingProgress"
:show-indicator="false"
:height="6"
:stroke-width="10"
:color="'#13987f'"
:rail-color="'#13987f30'"
class="assistant-view__progress"
type="line" />
<span class="assistant-view__placeholder-text">
{{ loadingProgress === 0 ? '开始加载模型...' : `模型加载中 ${loadingProgress}%` }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { convertFileSrc } from '@tauri-apps/api/core'
import {
ACESFilmicToneMapping,
AmbientLight,
AnimationAction,
AnimationClip,
AnimationMixer,
Box3,
Clock,
DirectionalLight,
Group,
Mesh,
PerspectiveCamera,
Scene,
Vector3,
WebGLRenderer,
SRGBColorSpace
} from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { ensureModelFile } from '@/utils/PathUtil'
import { isDesktop } from '@/utils/PlatformConstants'
const props = defineProps<{
active: boolean
customModel?: string | null
}>()
const emit = defineEmits<{
(event: 'ready'): void
(event: 'error', error: unknown): void
}>()
const container = ref<HTMLDivElement | null>(null)
const isReady = ref(false)
const loadingProgress = ref(0)
const TARGET_SIZE = 1.4
const LIFT_RATIO = 0.1
const TARGET_OFFSET_RATIO = 0.04
const CAMERA_DISTANCE_FACTOR = 2.2
const CAMERA_HEIGHT_FACTOR = 0.18
const MODEL_VERSION = '20251021'
const MODEL_FILE_NAME = `hula_${MODEL_VERSION}.glb`
const MODEL_REMOTE_URL = `https://cdn.hulaspark.com/models/hula.glb?v=${MODEL_VERSION}`
const clock = new Clock()
let renderer: WebGLRenderer | null = null
let scene: Scene | null = null
let camera: PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let model: Group | null = null
let modelWrapper: Group | null = null
let animationFrameId: number | null = null
let resizeObserver: ResizeObserver | null = null
let initialized = false
let activating = false
let defaultModelSourcePromise: Promise<string> | null = null
let currentCustomSource: string | null = null
let mixer: AnimationMixer | null = null
let activeAction: AnimationAction | null = null
let availableClips: AnimationClip[] = []
const resolveDefaultModelSource = async () => {
if (!defaultModelSourcePromise) {
defaultModelSourcePromise = (async () => {
if (!isDesktop()) {
return MODEL_REMOTE_URL
}
try {
const absolutePath = await ensureModelFile(MODEL_FILE_NAME, MODEL_REMOTE_URL)
return convertFileSrc(absolutePath)
} catch (error) {
console.warn('缓存 HuLa 模型失败, 回退远程资源', error)
return MODEL_REMOTE_URL
}
})().catch((error) => {
defaultModelSourcePromise = null
throw error
})
}
const url = await defaultModelSourcePromise
return url
}
const isInvalidBounds = (size: Vector3, center: Vector3) =>
size.lengthSq() === 0 || !Number.isFinite(center.x) || !Number.isFinite(center.y) || !Number.isFinite(center.z)
const resolveModelSource = async () => {
if (props.customModel) {
currentCustomSource = props.customModel
const url = isDesktop() ? convertFileSrc(props.customModel) : props.customModel
return url
}
currentCustomSource = null
return resolveDefaultModelSource()
}
const updateRendererSize = () => {
if (!renderer || !camera) return
const el = container.value
if (!el) return
const width = el.clientWidth || el.offsetWidth || 1
const height = el.clientHeight || el.offsetHeight || 1
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(width, height, false)
camera.aspect = width / height || 1
camera.updateProjectionMatrix()
}
const startLoop = () => {
if (!scene || !camera || !renderer) return
if (animationFrameId !== null) return
clock.start()
const currentScene = scene
const currentCamera = camera
const currentRenderer = renderer
const loop = () => {
animationFrameId = requestAnimationFrame(loop)
const delta = clock.getDelta()
mixer?.update(delta)
controls?.update()
currentRenderer.render(currentScene, currentCamera)
}
const el = container.value
if (el) {
if (!resizeObserver) {
resizeObserver = new ResizeObserver(() => {
updateRendererSize()
})
}
resizeObserver.observe(el)
}
window.addEventListener('resize', updateRendererSize, { passive: true })
updateRendererSize()
animationFrameId = requestAnimationFrame(loop)
}
const stopLoop = () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
clock.stop()
const el = container.value
if (el && resizeObserver) {
resizeObserver.unobserve(el)
}
window.removeEventListener('resize', updateRendererSize)
}
const disposeResources = () => {
stopLoop()
controls?.dispose()
controls = null
if (scene) {
scene.traverse((child) => {
if ((child as Mesh).isMesh) {
const mesh = child as Mesh
mesh.geometry.dispose()
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => material.dispose?.())
} else {
mesh.material?.dispose?.()
}
}
})
}
renderer?.dispose()
renderer = null
if (modelWrapper && scene) {
scene.remove(modelWrapper)
}
modelWrapper = null
scene = null
camera = null
model = null
mixer?.stopAllAction()
mixer = null
activeAction = null
availableClips = []
initialized = false
isReady.value = false
loadingProgress.value = 0
}
const adjustFraming = (scaledSize: Vector3, centerY: number) => {
if (!camera || !controls) return
const maxAxis = Math.max(scaledSize.x, scaledSize.y, scaledSize.z) || 1
const targetY = centerY - scaledSize.y * TARGET_OFFSET_RATIO
controls.target.set(0, targetY, 0)
controls.enableDamping = true
controls.enablePan = false
controls.autoRotate = true
controls.autoRotateSpeed = 0.8
controls.minDistance = maxAxis * 0.7
controls.maxDistance = maxAxis * 3
const distance = maxAxis * CAMERA_DISTANCE_FACTOR
camera.position.set(0, targetY + distance * CAMERA_HEIGHT_FACTOR, distance)
camera.near = Math.max(distance / 100, 0.1)
camera.far = distance * 10
camera.lookAt(controls.target)
camera.updateProjectionMatrix()
controls.update()
}
const loadModel = async () => {
if (!scene) {
throw new Error('场景尚未初始化')
}
loadingProgress.value = 0
isReady.value = false
if (!modelWrapper) {
modelWrapper = new Group()
scene.add(modelWrapper)
}
if (mixer) {
mixer.stopAllAction()
mixer = null
activeAction = null
}
availableClips = []
const modelSource = await resolveModelSource()
const loader = new GLTFLoader()
const result = await new Promise<{ scene: Group; extensions?: string[]; animations: AnimationClip[] }>(
(resolve, reject) => {
loader.load(
modelSource,
(gltf) => {
resolve({
scene: gltf.scene,
extensions: gltf.parser.json?.extensionsUsed,
animations: gltf.animations || []
})
},
(event) => {
const total = event.total || event.loaded
if (total) {
const percent = Math.round((event.loaded / total) * 100)
loadingProgress.value = Math.max(loadingProgress.value, Math.min(percent, 99))
}
},
(error) => {
loadingProgress.value = 0
console.error('[Assistant] 模型加载失败', modelSource, error)
reject(error)
}
)
}
)
const extensions = result.extensions ?? []
if (
currentCustomSource &&
extensions.some((ext) =>
['KHR_texture_basisu', 'KHR_draco_mesh_compression', 'EXT_meshopt_compression'].includes(ext)
)
) {
window.$message?.warning('暂不支持压缩后的 glb 模型,请选择原始模型文件')
throw new Error('UNSUPPORTED_COMPRESSED_MODEL')
}
const previousModel = model
const loadedModel = result.scene
const childrenSummary: Record<string, number> = {}
loadedModel.traverse((child) => {
const type = (child as Mesh).type
childrenSummary[type] = (childrenSummary[type] || 0) + 1
})
const box = new Box3().setFromObject(loadedModel)
const size = box.getSize(new Vector3())
const center = box.getCenter(new Vector3())
const hasInvalidBounds = isInvalidBounds(size, center)
if (hasInvalidBounds) {
window.$message?.warning('模型没有几何数据或存在损坏,请检查后重新导入')
throw new Error('EMPTY_MODEL_GEOMETRY')
}
const maxAxis = Math.max(size.x, size.y, size.z) || 1
const scale = TARGET_SIZE / maxAxis
const scaledSize = size.clone().multiplyScalar(scale)
const lift = scaledSize.y * LIFT_RATIO
const centerY = scaledSize.y / 2 + lift
if (previousModel && modelWrapper) {
modelWrapper.remove(previousModel)
previousModel.traverse((child) => {
if ((child as Mesh).isMesh) {
const mesh = child as Mesh
mesh.geometry.dispose()
if (Array.isArray(mesh.material)) {
mesh.material.forEach((material) => material.dispose?.())
} else {
mesh.material?.dispose?.()
}
}
})
}
model = loadedModel
modelWrapper?.add(loadedModel)
modelWrapper?.scale.setScalar(scale)
const scaledCenter = center.clone().multiplyScalar(scale)
if (modelWrapper) {
modelWrapper.position.set(-scaledCenter.x, centerY - scaledCenter.y, -scaledCenter.z)
}
availableClips = result.animations
if (availableClips.length > 0) {
mixer = new AnimationMixer(loadedModel)
const preferred =
AnimationClip.findByName(availableClips, 'Animation') ||
AnimationClip.findByName(availableClips, 'Armature|mixamo.com|Layer0') ||
availableClips[0]
activeAction = mixer.clipAction(preferred)
activeAction.reset().play()
} else {
console.debug('[Assistant] 模型没有动画片段')
}
adjustFraming(scaledSize, centerY)
loadingProgress.value = 100
isReady.value = true
initialized = true
}
const ensureScene = async () => {
const el = container.value
if (!el) {
throw new Error('未找到 HuLa 小管家容器')
}
if (!renderer) {
const width = el.clientWidth || el.offsetWidth || 1
const height = el.clientHeight || el.offsetHeight || 1
renderer = new WebGLRenderer({ antialias: true, alpha: true })
renderer.outputColorSpace = SRGBColorSpace
renderer.toneMapping = ACESFilmicToneMapping
renderer.toneMappingExposure = 1
renderer.shadowMap.enabled = true
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(width, height, false)
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.outline = 'none'
renderer.domElement.tabIndex = -1
el.appendChild(renderer.domElement)
scene = new Scene()
camera = new PerspectiveCamera(45, width / height || 1, 0.1, 100)
controls = new OrbitControls(camera, renderer.domElement)
const ambientLight = new AmbientLight(0xffffff, 1.1)
scene.add(ambientLight)
const directionalLight = new DirectionalLight(0xffffff, 1.4)
directionalLight.position.set(4, 6, 3)
directionalLight.castShadow = true
scene.add(directionalLight)
} else if (!el.contains(renderer.domElement)) {
el.appendChild(renderer.domElement)
}
if (!initialized) {
await loadModel()
initialized = true
}
updateRendererSize()
}
const activate = async () => {
if (activating || !props.active) return
activating = true
try {
await nextTick()
await ensureScene()
startLoop()
isReady.value = true
emit('ready')
} catch (error) {
emit('error', error)
} finally {
activating = false
}
}
const deactivate = () => {
stopLoop()
}
watch(
() => props.active,
(active) => {
if (active) {
void activate()
} else {
deactivate()
}
},
{ immediate: true }
)
watch(
() => props.customModel,
async () => {
if (!props.active || !scene) return
try {
await loadModel()
isReady.value = true
emit('ready')
} catch (error) {
emit('error', error)
}
}
)
onMounted(() => {
if (props.active) {
void activate()
}
})
onUnmounted(() => {
disposeResources()
})
</script>
<style scoped lang="scss">
.assistant-view {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: 16px;
border-radius: 16px;
background: radial-gradient(ellipse at center, rgba(19, 152, 127, 0.24), rgba(19, 152, 127, 0));
box-shadow: inset 0 0 0 1px rgba(19, 152, 127, 0.1);
overflow: hidden;
canvas {
width: 100%;
height: 100%;
display: block;
}
}
.assistant-view__placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 14px;
color: #13987f;
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(8px);
}
.assistant-view__progress {
width: 40%;
min-width: 180px;
}
.assistant-view__placeholder-text {
padding-top: 12px;
font-size: 14px;
color: var(--text-color);
}
</style>
+27 -2
View File
@@ -1,4 +1,4 @@
import { MittEnum, NotificationTypeEnum, RoomTypeEnum, SessionOperateEnum } from '@/enums'
import { MittEnum, NotificationTypeEnum, RoomTypeEnum, SessionOperateEnum, UserType } from '@/enums'
import { useMitt } from '@/hooks/useMitt.ts'
import type { SessionItem } from '@/services/types.ts'
import { useChatStore } from '@/stores/chat.ts'
@@ -17,6 +17,7 @@ export const useMessage = () => {
const settingStore = useSettingStore()
const { chat } = storeToRefs(settingStore)
const contactStore = useContactStore()
const BOT_ALLOWED_MENU_INDEXES = new Set([0, 1, 2, 3])
/** 监听独立窗口关闭事件 */
watchEffect(() => {
useMitt.on(MittEnum.SHRINK_WINDOW, async (event: any) => {
@@ -308,5 +309,29 @@ export const useMessage = () => {
window.$message.success(message)
}
return { msgBoxShow, handleMsgClick, handleMsgDelete, handleMsgDblclick, menuList, specialMenuList, preloadChatRoom }
const visibleMenu = (item: SessionItem) => {
if (item.account === UserType.BOT) {
return menuList.value.filter((_, index) => BOT_ALLOWED_MENU_INDEXES.has(index))
}
return menuList.value
}
const visibleSpecialMenu = (item: SessionItem) => {
if (item.account === UserType.BOT) {
return []
}
return specialMenuList.value
}
return {
msgBoxShow,
handleMsgClick,
handleMsgDelete,
handleMsgDblclick,
menuList,
specialMenuList,
visibleMenu,
visibleSpecialMenu,
preloadChatRoom
}
}
+9
View File
@@ -37,6 +37,15 @@ if (isMobile()) {
} else {
setup()
}
// TODO 需要测试
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/hula-model-sw.js')
.catch((error) => console.warn('ServiceWorker registration failed:', error))
})
}
}
async function setup() {
+69 -10
View File
@@ -1,18 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
import { appCacheDir, join, resourceDir } from '@tauri-apps/api/path'
import { BaseDirectory, exists, mkdir, readFile } from '@tauri-apps/plugin-fs'
import { BaseDirectory, exists, mkdir, readFile, writeFile } from '@tauri-apps/plugin-fs'
import { type FileTypeResult, fileTypeFromBuffer } from 'file-type'
import type { FilesMeta } from '@/services/types'
// 用户数据
// Tauri 资源目录下存放用户数据的根目录名
const USER_DATA = 'userData'
// 统一存放三维模型的子目录名
const MODELS_DIR = 'models'
const getPathCache = async (subFolder: string, userUid: string): Promise<string> => {
const cacheDir = await appCacheDir()
return await join(cacheDir, String(userUid), subFolder)
}
const createUserVideosDir = async (): Promise<void> => {
/**
* 确保资源目录下存在 userData 根目录。
* Tauri 在构建后默认不会创建该目录,需要在第一次使用前主动创建。
*/
const ensureUserDataRoot = async (): Promise<void> => {
const dirExists = await exists(USER_DATA, { baseDir: BaseDirectory.Resource })
if (!dirExists) {
await mkdir(USER_DATA, {
@@ -22,8 +23,23 @@ const createUserVideosDir = async (): Promise<void> => {
}
}
/**
* 获取缓存目录,路径结构为:appCacheDir/userUid/subFolder
* @param subFolder 子目录名
* @param userUid 当前用户ID
*/
const getPathCache = async (subFolder: string, userUid: string): Promise<string> => {
const cacheDir = await appCacheDir()
return await join(cacheDir, String(userUid), subFolder)
}
/**
* 获取用户视频文件夹(userData/userUid/roomId),并确保整个路径存在。
* @param userUid 用户ID
* @param roomId 房间ID
*/
const getUserVideosDir = async (userUid: string, roomId: string): Promise<string> => {
await createUserVideosDir()
await ensureUserDataRoot()
// 确保用户ID和房间ID的子目录也存在
const userRoomDir = await join(USER_DATA, userUid, roomId)
const userRoomDirExists = await exists(userRoomDir, { baseDir: BaseDirectory.Resource })
@@ -49,6 +65,49 @@ const getImageCache = (subFolder: string, userUid: string): string => {
return 'cache/' + String(userUid) + '/' + subFolder + '/'
}
/**
* 确保模型存储目录 userData/models 存在,并返回该目录的相对路径。
*/
const ensureModelsDir = async (): Promise<string> => {
await ensureUserDataRoot()
const modelsPath = await join(USER_DATA, MODELS_DIR)
const hasModelsDir = await exists(modelsPath, { baseDir: BaseDirectory.Resource })
if (!hasModelsDir) {
await mkdir(modelsPath, {
baseDir: BaseDirectory.Resource,
recursive: true
})
}
return modelsPath
}
/**
* 确保指定模型文件已经缓存在本地。
* 若不存在则从远程链接下载并写入 userData/models 目录。
* 最终返回模型文件在资源目录下的绝对路径。
* @param fileName 模型文件名,如 hula.glb
* @param remoteUrl 模型远程下载地址
*/
export const ensureModelFile = async (fileName: string, remoteUrl: string): Promise<string> => {
const modelsDir = await ensureModelsDir()
const modelRelativePath = await join(modelsDir, fileName)
const modelExists = await exists(modelRelativePath, { baseDir: BaseDirectory.Resource })
if (!modelExists) {
const response = await fetch(remoteUrl)
if (!response.ok) {
throw new Error(`下载模型失败: ${response.status} ${response.statusText}`)
}
const buffer = await response.arrayBuffer()
await writeFile(modelRelativePath, new Uint8Array(buffer), {
baseDir: BaseDirectory.Resource
})
}
const resourceDirPath = await resourceDir()
return await join(resourceDirPath, modelRelativePath)
}
/**
* 解析远程文件的真实类型
* @param url 文件的远程地址
@@ -155,4 +214,4 @@ export async function getFilesMeta<T>(filesPath: string[]) {
})
}
export { getPathCache, createUserVideosDir, getUserVideosDir, getImageCache }
export { getPathCache, getUserVideosDir, getImageCache }
+4 -2
View File
@@ -60,7 +60,8 @@
<div class="text leading-tight text-12px flex-y-center gap-4px flex-1 truncate">
[
<template v-if="getUserState(item.uid)">
<template v-if="isBotUser(item.uid)">助手</template>
<template v-else-if="getUserState(item.uid)">
<img class="size-12px rounded-50%" :src="getUserState(item.uid)?.url" alt="" />
{{ getUserState(item.uid)?.title }}
</template>
@@ -114,7 +115,7 @@
<script setup lang="ts" name="friendsList">
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import { MittEnum, OnlineEnum, RoomTypeEnum, ThemeEnum } from '@/enums'
import { MittEnum, OnlineEnum, RoomTypeEnum, ThemeEnum, UserType } from '@/enums'
import { useMitt } from '@/hooks/useMitt.ts'
import type { DetailsContent } from '@/services/types'
import { useContactStore } from '@/stores/contacts.ts'
@@ -222,6 +223,7 @@ const fetchContactData = async () => {
}
}
const isBotUser = (uid: string) => groupStore.getUserInfo(uid)?.account === UserType.BOT
/** 获取用户状态 */
const getUserState = (uid: string) => {
const userInfo = groupStore.getUserInfo(uid)
+12 -5
View File
@@ -16,8 +16,8 @@
}
]"
:data-key="item.roomId"
:menu="menuList"
:special-menu="specialMenuList"
:menu="visibleMenu(item)"
:special-menu="visibleSpecialMenu(item)"
:content="item"
class="msg-box w-full h-75px mb-5px"
@click="handleMsgClick(item)"
@@ -48,7 +48,11 @@
<span>官方群聊认证</span>
</n-popover>
</n-flex>
<span class="text text-10px w-fit truncate text-right">{{ item.lastMsgTime }}</span>
<span
:class="{ 'text-[#707070]! dark:text-[#fff]!': item.account === UserType.BOT }"
class="text text-10px w-fit truncate text-right">
{{ item.lastMsgTime }}
</span>
</n-flex>
<n-flex align="center" justify="space-between">
@@ -59,7 +63,10 @@
</template>
<template v-else>
<span
class="text flex-1 leading-tight text-12px truncate"
:class="[
'text flex-1 leading-tight text-12px truncate',
{ 'text-[#707070]! dark:text-[#fff]!': item.account === UserType.BOT }
]"
v-text="String(item.lastMsg || '').replace(':', ':')" />
</template>
@@ -142,7 +149,7 @@ const { themes } = storeToRefs(settingStore)
const botDisplayText = computed(() => botStore.displayText)
const { openMsgSession } = useCommon()
const msgScrollbar = useTemplateRef<HTMLElement>('msg-scrollbar')
const { handleMsgClick, handleMsgDelete, menuList, specialMenuList, handleMsgDblclick } = useMessage()
const { handleMsgClick, handleMsgDelete, handleMsgDblclick, visibleMenu, visibleSpecialMenu } = useMessage()
// 跟踪当前显示右键菜单的会话ID
const activeContextMenuRoomId = ref<string | null>(null)