diff --git a/local-tool/branding/README.md b/local-tool/branding/README.md new file mode 100644 index 0000000..3647dc9 --- /dev/null +++ b/local-tool/branding/README.md @@ -0,0 +1,61 @@ +# visionA Local — Branding Assets + +此目錄存放 visionA Local 的品牌視覺資產。 + +## 檔案 + +| 檔案 | 用途 | +|------|------| +| `logo.svg` | 向量原始設計稿(1024×1024) | +| `icon-1024.png` | Wails build 的 appicon(會被 Wails 複製到 `visiona-local/build/appicon.png`) | +| `icon-512.png` / `icon-256.png` / `icon-128.png` | 各尺寸 PNG 備用 | +| `icon.ico` | Windows 多解析度 ICO(16/24/32/48/64/96/128/256,PNG-in-ICO 格式) | +| `icon.icns` | macOS .app bundle icon | + +## 如何更新 logo + +1. 改 `logo.svg` 或改 `gen_icon.go` 的繪圖函數 +2. 跑 `go run gen_icon.go ` 產出各尺寸 PNG +3. 跑 `go run gen_ico.go icon.ico 16,24,32,48,64,96,128,256` 產 Windows ICO +4. macOS icns 用 `iconutil`: + ```bash + mkdir icon.iconset + cp icon-16.png icon.iconset/icon_16x16.png + cp icon-32.png icon.iconset/icon_16x16@2x.png + cp icon-32.png icon.iconset/icon_32x32.png + cp icon-64.png icon.iconset/icon_32x32@2x.png + cp icon-128.png icon.iconset/icon_128x128.png + cp icon-256.png icon.iconset/icon_128x128@2x.png + cp icon-256.png icon.iconset/icon_256x256.png + cp icon-512.png icon.iconset/icon_256x256@2x.png + cp icon-512.png icon.iconset/icon_512x512.png + cp icon-1024.png icon.iconset/icon_512x512@2x.png + iconutil -c icns icon.iconset -o icon.icns + ``` +5. 部署: + - `cp icon-1024.png ../visiona-local/build/appicon.png` — Wails build + - `cp icon-256.png ../visiona-local/frontend/icon.png` — splash page + - `cp icon.ico ../frontend/src/app/favicon.ico` — Next.js favicon + - `cp icon-256.png ../frontend/src/app/icon.png` — Next.js App Router icon +6. 重 build:`make clean-all && make exe`(Windows)或 `make clean-all && make dmg`(macOS) + +## 設計理念 + +- 圓角方形背景(符合現代 app icon 容器標準) +- 深藍漸層底(`#1A1F36` → `#0E1222`)傳達專業、科技感 +- 雙層同心圓環 = 相機鏡頭 / 視覺感測器隱喻 +- 中央「V」字形 = vision 首字母 +- 三個 pixel 點 + 右上 active indicator = Edge AI / pixel-level 運算的視覺語彙 +- 主色 `#4F7EFF`(電子藍)搭配 `#6EF3C5`(mint)點綴,避免純藍的冰冷 + +## 色票 + +| 用途 | HEX | +|------|-----| +| 主色 | `#4F7EFF` | +| 主色亮色 | `#6EA8FF` | +| 點綴 | `#6EF3C5` | +| 深色背景頂 | `#1A1F36` | +| 深色背景底 | `#0E1222` | +| 警示 | `#FF6B6B` | +| 中性灰 | `#8890B0` | diff --git a/local-tool/branding/icon-1024.png b/local-tool/branding/icon-1024.png new file mode 100644 index 0000000..98c6d04 Binary files /dev/null and b/local-tool/branding/icon-1024.png differ diff --git a/local-tool/branding/icon-128.png b/local-tool/branding/icon-128.png new file mode 100644 index 0000000..0f9be24 Binary files /dev/null and b/local-tool/branding/icon-128.png differ diff --git a/local-tool/branding/icon-256.png b/local-tool/branding/icon-256.png new file mode 100644 index 0000000..09717b5 Binary files /dev/null and b/local-tool/branding/icon-256.png differ diff --git a/local-tool/branding/icon-512.png b/local-tool/branding/icon-512.png new file mode 100644 index 0000000..9c51f5a Binary files /dev/null and b/local-tool/branding/icon-512.png differ diff --git a/local-tool/branding/icon.icns b/local-tool/branding/icon.icns new file mode 100644 index 0000000..0e00b8b Binary files /dev/null and b/local-tool/branding/icon.icns differ diff --git a/local-tool/branding/icon.ico b/local-tool/branding/icon.ico new file mode 100644 index 0000000..403176c Binary files /dev/null and b/local-tool/branding/icon.ico differ diff --git a/local-tool/branding/logo.svg b/local-tool/branding/logo.svg new file mode 100644 index 0000000..b7462ea --- /dev/null +++ b/local-tool/branding/logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/local-tool/branding/tools/gen_ico.go b/local-tool/branding/tools/gen_ico.go new file mode 100644 index 0000000..bd90883 --- /dev/null +++ b/local-tool/branding/tools/gen_ico.go @@ -0,0 +1,95 @@ +// 把多張 PNG 打包成 Windows .ico(PNG-in-ICO 格式,Vista+ 支援) +// 用法:go run gen_ico.go +// 範例:go run gen_ico.go icon.ico ../ 16,24,32,48,64,96,128,256 +//go:build ignore + +package main + +import ( + "bytes" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type iconDir struct { + Reserved uint16 + Type uint16 + Count uint16 +} + +type iconDirEntry struct { + Width uint8 + Height uint8 + ColorCount uint8 + Reserved uint8 + Planes uint16 + BitCount uint16 + SizeBytes uint32 + Offset uint32 +} + +func main() { + if len(os.Args) < 4 { + fmt.Fprintln(os.Stderr, "usage: gen_ico ") + os.Exit(1) + } + outPath := os.Args[1] + pngDir := os.Args[2] + sizesCSV := os.Args[3] + + var sizes []int + for _, s := range strings.Split(sizesCSV, ",") { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + fmt.Fprintln(os.Stderr, "bad size:", s) + os.Exit(1) + } + sizes = append(sizes, n) + } + + pngs := make([][]byte, len(sizes)) + for i, s := range sizes { + data, err := os.ReadFile(filepath.Join(pngDir, "icon-"+strconv.Itoa(s)+".png")) + if err != nil { + fmt.Fprintln(os.Stderr, "read", s, err) + os.Exit(1) + } + pngs[i] = data + } + + var buf bytes.Buffer + binary.Write(&buf, binary.LittleEndian, iconDir{Reserved: 0, Type: 1, Count: uint16(len(sizes))}) + headerSize := 6 + 16*len(sizes) + offset := uint32(headerSize) + for i, s := range sizes { + w := uint8(s) + h := uint8(s) + if s >= 256 { + w = 0 + h = 0 + } + entry := iconDirEntry{ + Width: w, + Height: h, + Planes: 1, + BitCount: 32, + SizeBytes: uint32(len(pngs[i])), + Offset: offset, + } + binary.Write(&buf, binary.LittleEndian, entry) + offset += uint32(len(pngs[i])) + } + for _, p := range pngs { + buf.Write(p) + } + + if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { + fmt.Fprintln(os.Stderr, "write", err) + os.Exit(1) + } + fmt.Println("wrote", outPath, "containing", len(sizes), "images") +} diff --git a/local-tool/branding/tools/gen_icon.go b/local-tool/branding/tools/gen_icon.go new file mode 100644 index 0000000..44e164a --- /dev/null +++ b/local-tool/branding/tools/gen_icon.go @@ -0,0 +1,289 @@ +// 一次性 icon 生成工具 — 產 visionA Local logo 的多種解析度 PNG +// 用法:go run gen_icon.go +// +// 此檔案為 standalone 工具,不屬於 visiona-local app。 +// 用 build tag 避免被一般 go build 抓到(只有明確 go run 才執行)。 +//go:build ignore + +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "strconv" +) + +var ( + bgTop = color.RGBA{0x1A, 0x1F, 0x36, 0xFF} + bgBottom = color.RGBA{0x0E, 0x12, 0x22, 0xFF} + lensTop = color.RGBA{0x6E, 0xA8, 0xFF, 0xFF} + lensBot = color.RGBA{0x4F, 0x7E, 0xFF, 0xFF} + lensMid = color.RGBA{0x4F, 0x7E, 0xFF, 0x99} + centerGlow = color.RGBA{0x6E, 0xF3, 0xC5, 0xE0} + white = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} + mint = color.RGBA{0x6E, 0xF3, 0xC5, 0xFF} + lightBlue = color.RGBA{0x6E, 0xA8, 0xFF, 0xFF} +) + +func lerpColor(a, b color.RGBA, t float64) color.RGBA { + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + return color.RGBA{ + R: uint8(float64(a.R)*(1-t) + float64(b.R)*t), + G: uint8(float64(a.G)*(1-t) + float64(b.G)*t), + B: uint8(float64(a.B)*(1-t) + float64(b.B)*t), + A: uint8(float64(a.A)*(1-t) + float64(b.A)*t), + } +} + +func blendAlpha(dst, src color.RGBA) color.RGBA { + if src.A == 0 { + return dst + } + if src.A == 0xFF { + return src + } + sa := float64(src.A) / 255.0 + da := float64(dst.A) / 255.0 + out := 1.0 - (1.0-sa)*(1.0-da) + if out == 0 { + return dst + } + return color.RGBA{ + R: uint8((float64(src.R)*sa + float64(dst.R)*da*(1-sa)) / out), + G: uint8((float64(src.G)*sa + float64(dst.G)*da*(1-sa)) / out), + B: uint8((float64(src.B)*sa + float64(dst.B)*da*(1-sa)) / out), + A: uint8(out * 255), + } +} + +func rgba(c color.Color) color.RGBA { + if r, ok := c.(color.RGBA); ok { + return r + } + r, g, b, a := c.RGBA() + return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)} +} + +func drawRoundedRect(img *image.RGBA, size int, radius float64, colorAt func(y int) color.RGBA) { + cx, cy := float64(size)/2, float64(size)/2 + for y := 0; y < size; y++ { + c := colorAt(y) + for x := 0; x < size; x++ { + dx := math.Max(0, math.Abs(float64(x)-cx+0.5)-(float64(size)/2-radius)) + dy := math.Max(0, math.Abs(float64(y)-cy+0.5)-(float64(size)/2-radius)) + d := math.Hypot(dx, dy) + if d <= radius { + a := 1.0 + if d > radius-1 { + a = radius - d + } + if a < 0 { + continue + } + if a > 1 { + a = 1 + } + cc := c + cc.A = uint8(float64(cc.A) * a) + img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc)) + } + } + } +} + +func drawCircleRing(img *image.RGBA, cx, cy, rOuter, rInner float64, c color.RGBA) { + size := img.Bounds().Dx() + for y := int(cy-rOuter-1) - 1; y <= int(cy+rOuter+1)+1; y++ { + for x := int(cx-rOuter-1) - 1; x <= int(cx+rOuter+1)+1; x++ { + if x < 0 || y < 0 || x >= size || y >= size { + continue + } + d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5) + if d <= rOuter && d >= rInner { + a := 1.0 + if d > rOuter-1 { + a = rOuter - d + } else if d < rInner+1 { + a = d - rInner + } + if a < 0 { + continue + } + if a > 1 { + a = 1 + } + cc := c + cc.A = uint8(float64(cc.A) * a) + img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc)) + } + } + } +} + +func drawFilledCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA) { + size := img.Bounds().Dx() + for y := int(cy-r-1) - 1; y <= int(cy+r+1)+1; y++ { + for x := int(cx-r-1) - 1; x <= int(cx+r+1)+1; x++ { + if x < 0 || y < 0 || x >= size || y >= size { + continue + } + d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5) + if d <= r { + a := 1.0 + if d > r-1 { + a = r - d + } + if a < 0 { + continue + } + cc := c + cc.A = uint8(float64(cc.A) * a) + img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc)) + } + } + } +} + +func drawRadialGlow(img *image.RGBA, cx, cy, r float64, c color.RGBA) { + size := img.Bounds().Dx() + for y := int(cy-r-1) - 1; y <= int(cy+r+1)+1; y++ { + for x := int(cx-r-1) - 1; x <= int(cx+r+1)+1; x++ { + if x < 0 || y < 0 || x >= size || y >= size { + continue + } + d := math.Hypot(float64(x)-cx+0.5, float64(y)-cy+0.5) + if d <= r { + t := 1.0 - d/r + t = t * t + cc := c + cc.A = uint8(float64(cc.A) * t) + img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc)) + } + } + } +} + +func drawLine(img *image.RGBA, x1, y1, x2, y2, width float64, c color.RGBA) { + size := img.Bounds().Dx() + r := width / 2 + minX := int(math.Min(x1, x2) - r - 1) + maxX := int(math.Max(x1, x2) + r + 1) + minY := int(math.Min(y1, y2) - r - 1) + maxY := int(math.Max(y1, y2) + r + 1) + dx := x2 - x1 + dy := y2 - y1 + lenSq := dx*dx + dy*dy + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + if x < 0 || y < 0 || x >= size || y >= size { + continue + } + px := float64(x) + 0.5 + py := float64(y) + 0.5 + var t float64 + if lenSq > 0 { + t = ((px-x1)*dx + (py-y1)*dy) / lenSq + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + } + cx := x1 + t*dx + cy := y1 + t*dy + d := math.Hypot(px-cx, py-cy) + if d <= r { + a := 1.0 + if d > r-1 { + a = r - d + } + if a < 0 { + continue + } + cc := c + cc.A = uint8(float64(cc.A) * a) + img.Set(x, y, blendAlpha(rgba(img.At(x, y)), cc)) + } + } + } +} + +func renderLogo(size int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, size, size)) + s := float64(size) + k := s / 1024.0 + + radius := 220.0 * k + drawRoundedRect(img, size, radius, func(y int) color.RGBA { + t := float64(y) / s + return lerpColor(bgTop, bgBottom, t) + }) + + cx := 512.0 * k + cy := 512.0 * k + outerR := 360.0 * k + outerW := 36.0 * k + drawCircleRing(img, cx, cy, outerR, outerR-outerW, lensTop) + drawCircleRing(img, cx, cy, outerR, outerR-outerW/2, lerpColor(lensTop, lensBot, 0.6)) + + midR := 296.0 * k + midW := 14.0 * k + drawCircleRing(img, cx, cy, midR, midR-midW, lensMid) + + glowR := 240.0 * k + drawRadialGlow(img, cx, cy, glowR, centerGlow) + + lineW := 44.0 * k + drawLine(img, 360*k, 360*k, 512*k, 640*k, lineW, white) + drawLine(img, 664*k, 360*k, 512*k, 640*k, lineW, white) + + drawFilledCircle(img, 360*k, 360*k, 22*k, mint) + drawFilledCircle(img, 664*k, 360*k, 22*k, lightBlue) + drawFilledCircle(img, 512*k, 640*k, 26*k, white) + + drawFilledCircle(img, 760*k, 264*k, 14*k, mint) + drawCircleRing(img, 760*k, 264*k, 24*k, 21*k, color.RGBA{0x6E, 0xF3, 0xC5, 0x66}) + + return img +} + +func savePNG(img *image.RGBA, path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: go run gen_icon.go ") + os.Exit(1) + } + outDir := os.Args[1] + sizes := []int{16, 24, 32, 48, 64, 96, 128, 256, 512, 1024} + for _, s := range sizes { + img := renderLogo(s) + path := filepath.Join(outDir, "icon-"+strconv.Itoa(s)+".png") + if err := savePNG(img, path); err != nil { + fmt.Fprintln(os.Stderr, "save", path, err) + os.Exit(1) + } + fmt.Println("wrote", path) + } +} diff --git a/local-tool/frontend/src/app/favicon.ico b/local-tool/frontend/src/app/favicon.ico index 718d6fe..403176c 100644 Binary files a/local-tool/frontend/src/app/favicon.ico and b/local-tool/frontend/src/app/favicon.ico differ diff --git a/local-tool/frontend/src/app/icon.png b/local-tool/frontend/src/app/icon.png new file mode 100644 index 0000000..09717b5 Binary files /dev/null and b/local-tool/frontend/src/app/icon.png differ diff --git a/local-tool/frontend/src/app/layout.tsx b/local-tool/frontend/src/app/layout.tsx index 3f175bc..9a97aa9 100644 --- a/local-tool/frontend/src/app/layout.tsx +++ b/local-tool/frontend/src/app/layout.tsx @@ -20,8 +20,14 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "visionA-local", - description: "Local-first Edge AI development tool", + title: "visionA Local — Edge AI Workspace", + description: "A local-first Edge AI workspace for Kneron devices. Manage hardware, models, cameras, and inference pipelines on your own machine.", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/icon.png", type: "image/png" }, + ], + }, }; export default function RootLayout({ diff --git a/local-tool/frontend/src/lib/i18n/en.ts b/local-tool/frontend/src/lib/i18n/en.ts index 1cb22d5..0c0be65 100644 --- a/local-tool/frontend/src/lib/i18n/en.ts +++ b/local-tool/frontend/src/lib/i18n/en.ts @@ -29,9 +29,9 @@ export const en: TranslationDict = { devices: 'Devices', workspace: 'Workspace', settings: 'Settings', - appName: 'visionA', - version: 'visionA local v0.1.0', - platformTitle: 'visionA — Local Edge AI', + appName: 'visionA Local', + version: 'visionA Local v0.1.0', + platformTitle: 'visionA Local — Edge AI Workspace', serverConnected: 'Server Connected', serverDisconnected: 'Server Disconnected', }, @@ -44,7 +44,7 @@ export const en: TranslationDict = { }, dashboard: { title: 'Dashboard', - subtitle: 'Overview of your Edge AI platform', + subtitle: 'Overview of your visionA workspace', models: 'Models', devices: 'Devices', connected: 'Connected', @@ -319,7 +319,7 @@ export const en: TranslationDict = { languageEn: 'English', about: 'About', versionLabel: 'Version', - platform: 'Edge AI Development Platform', + platform: 'visionA Local — Edge AI Workspace', resetToDefaults: 'Reset to Defaults', resetSuccess: 'Settings restored to defaults', serverLogs: { @@ -360,7 +360,7 @@ export const en: TranslationDict = { }, }, onboarding: { - welcome: 'Welcome to Edge AI Platform!', + welcome: 'Welcome to visionA Local!', step1Title: 'Connect Your Device', step1Desc: 'Plug in your Kneron USB Dongle and scan for devices. The platform will automatically detect supported hardware.', step2Title: 'Choose an AI Model', diff --git a/local-tool/frontend/src/lib/i18n/zh-TW.ts b/local-tool/frontend/src/lib/i18n/zh-TW.ts index 4cbb846..621630d 100644 --- a/local-tool/frontend/src/lib/i18n/zh-TW.ts +++ b/local-tool/frontend/src/lib/i18n/zh-TW.ts @@ -29,9 +29,9 @@ export const zhTW: TranslationDict = { devices: '裝置', workspace: '工作區', settings: '設定', - appName: 'visionA', - version: 'visionA local v0.1.0', - platformTitle: 'visionA — 本機 Edge AI', + appName: 'visionA Local', + version: 'visionA Local v0.1.0', + platformTitle: 'visionA Local — 邊緣 AI 工作站', serverConnected: '伺服器已連線', serverDisconnected: '伺服器未連線', }, @@ -44,7 +44,7 @@ export const zhTW: TranslationDict = { }, dashboard: { title: '儀表板', - subtitle: 'Edge AI 平台總覽', + subtitle: 'visionA 工作站總覽', models: '模型', devices: '裝置', connected: '已連線', @@ -319,7 +319,7 @@ export const zhTW: TranslationDict = { languageEn: 'English', about: '關於', versionLabel: '版本', - platform: 'Edge AI 開發平台', + platform: 'visionA Local — 邊緣 AI 工作站', resetToDefaults: '恢復預設值', resetSuccess: '設定已恢復預設值', serverLogs: { @@ -360,7 +360,7 @@ export const zhTW: TranslationDict = { }, }, onboarding: { - welcome: '歡迎使用 Edge AI 平台!', + welcome: '歡迎使用 visionA Local!', step1Title: '連接你的裝置', step1Desc: '插入 Kneron USB Dongle 並掃描裝置。平台將自動偵測支援的硬體。', step2Title: '選擇 AI 模型', diff --git a/local-tool/installer/windows/visiona-local.iss b/local-tool/installer/windows/visiona-local.iss index 97adac1..0ef90fe 100644 --- a/local-tool/installer/windows/visiona-local.iss +++ b/local-tool/installer/windows/visiona-local.iss @@ -13,11 +13,12 @@ ; 所有路徑相對於本 .iss 所在目錄 (installer/windows/) ; 因此 ..\.. 會指回專案根目錄 (local-tool/) -#define MyAppName "visionA-local" +#define MyAppName "visionA Local" #define MyAppVersion "0.1.0" #define MyAppPublisher "Innovedus" #define MyAppURL "https://github.com/Innovedus/visiona-local" #define MyAppExeName "visiona-local.exe" +#define MyAppIcon "..\..\branding\icon.ico" [Setup] ; AppId 固定 GUID(產品識別用,未來升級時不可更動) @@ -39,8 +40,12 @@ OutputBaseFilename=visiona-local-{#MyAppVersion}-windows-x64 Compression=lzma2/ultra SolidCompression=yes WizardStyle=modern -ArchitecturesAllowed=x64 -ArchitecturesInstallIn64BitMode=x64 +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +; Installer 自身 icon +SetupIconFile={#MyAppIcon} +; 解除安裝程式在「設定 > 應用程式」顯示的 icon +UninstallDisplayIcon={app}\{#MyAppExeName} ; Windows 10 1809 以上(WinUSB / pnputil 需求) MinVersion=10.0.17763 ; 不需要 code signing(使用者決策 Q2=C) diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 87921d5..2b55303 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -172,6 +172,13 @@ func (a *App) startup(ctx context.Context) { fmt.Fprintln(os.Stderr, "[visiona-local] IPC server start failed:", err) } + // 3.5. 首次啟動 seed:把 installer 內建的 models.json / nef 預置模型 / scripts + // 複製到 user data-dir,讓 server 能在 --data-dir= 情境下讀到內建模型。 + // 失敗不擋啟動,只是 server 啟動後模型庫會是空的。 + if err := a.seedUserDataDir(); err != nil { + fmt.Fprintln(os.Stderr, "[visiona-local] seed user data dir failed:", err) + } + // 4. 啟動 server 子行程 if err := a.startServer(); err != nil { a.reportFatal("server start failed", err) @@ -771,6 +778,134 @@ func locateBundleBinDir() (string, error) { return "", fmt.Errorf("bundle bin dir not found") } +// locateBundleDataDir 找 installer 打包的 data 目錄(含 models.json + nef/ 預置模型)。 +// +// 順序: +// 1. macOS .app bundle:Contents/Resources/data +// 2. Windows / Linux 打包後:與 Wails 執行檔同目錄下的 data/ +// 3. 開發模式:/server/data(相對 cwd,或往上一層 / 兩層) +func locateBundleDataDir() (string, error) { + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + if runtime.GOOS == "darwin" { + d := filepath.Join(exeDir, "..", "Resources", "data") + if dirExists(d) { + abs, _ := filepath.Abs(d) + return abs, nil + } + } + // 同目錄 data/ fallback(Windows / Linux Inno Setup / AppImage 佈局) + d := filepath.Join(exeDir, "data") + if dirExists(d) { + abs, _ := filepath.Abs(d) + return abs, nil + } + } + + // 開發模式 fallback:/server/data + if cwd, err := os.Getwd(); err == nil { + candidates := []string{ + filepath.Join(cwd, "server", "data"), + filepath.Join(cwd, "..", "server", "data"), + filepath.Join(cwd, "..", "..", "server", "data"), + } + for _, c := range candidates { + if dirExists(c) { + abs, _ := filepath.Abs(c) + return abs, nil + } + } + } + + return "", fmt.Errorf("bundle data dir not found") +} + +// seedUserDataDir 首次啟動時把 installer 內建的 data/ 內容(models.json / nef/ / scripts/) +// 複製到 user data-dir,讓 server 能在 --data-dir= 情境下讀到內建模型。 +// +// 只複製缺失的檔案,不覆蓋使用者已有的資料。 +// +// 複製清單(相對於 bundle data dir): +// - models.json → 預置模型 metadata +// - nef/ → Kneron 預置 .nef 模型檔 +func (a *App) seedUserDataDir() error { + bundleDataDir, err := locateBundleDataDir() + if err != nil { + return fmt.Errorf("locate bundle data dir: %w", err) + } + + // 檢查 user data-dir 是否已經有 models.json(表示已 seed 過) + userModelsJSON := filepath.Join(a.dataDir, "models.json") + if fileExists(userModelsJSON) { + return nil // 已 seed,跳過 + } + + fmt.Fprintln(os.Stderr, "[visiona-local] first-run: seeding user data dir from", bundleDataDir) + + // 1. 複製 models.json + srcJSON := filepath.Join(bundleDataDir, "models.json") + if fileExists(srcJSON) { + if err := copyFile(srcJSON, userModelsJSON); err != nil { + return fmt.Errorf("copy models.json: %w", err) + } + fmt.Fprintln(os.Stderr, "[visiona-local] + models.json") + } else { + fmt.Fprintln(os.Stderr, "[visiona-local] - bundle models.json not found at", srcJSON) + } + + // 2. 遞迴複製 nef/ 預置模型目錄 + srcNefDir := filepath.Join(bundleDataDir, "nef") + dstNefDir := filepath.Join(a.dataDir, "nef") + if dirExists(srcNefDir) { + if err := copyDirRecursive(srcNefDir, dstNefDir); err != nil { + return fmt.Errorf("copy nef dir: %w", err) + } + fmt.Fprintln(os.Stderr, "[visiona-local] + nef/") + } + + return nil +} + +// copyFile 複製單一檔案,若目標檔已存在則覆蓋。 +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +// copyDirRecursive 遞迴複製目錄,保留結構。已存在的檔案不覆蓋(skip)。 +func copyDirRecursive(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + if fileExists(target) { + return nil // 不覆蓋已存在的檔案 + } + return copyFile(path, target) + }) +} + func fileExists(p string) bool { info, err := os.Stat(p) return err == nil && !info.IsDir() diff --git a/local-tool/visiona-local/build/appicon.png b/local-tool/visiona-local/build/appicon.png index 63617fe..98c6d04 100644 Binary files a/local-tool/visiona-local/build/appicon.png and b/local-tool/visiona-local/build/appicon.png differ diff --git a/local-tool/visiona-local/frontend/icon.png b/local-tool/visiona-local/frontend/icon.png new file mode 100644 index 0000000..09717b5 Binary files /dev/null and b/local-tool/visiona-local/frontend/icon.png differ diff --git a/local-tool/visiona-local/frontend/index.html b/local-tool/visiona-local/frontend/index.html index febf98d..7090a75 100644 --- a/local-tool/visiona-local/frontend/index.html +++ b/local-tool/visiona-local/frontend/index.html @@ -4,12 +4,17 @@ visionA Local +
- + visionA Local +
+
visionA Local
+
Edge AI Workspace
+
正在啟動伺服器...
diff --git a/local-tool/visiona-local/frontend/style.css b/local-tool/visiona-local/frontend/style.css index 23d6927..d93fd3c 100644 --- a/local-tool/visiona-local/frontend/style.css +++ b/local-tool/visiona-local/frontend/style.css @@ -1,12 +1,25 @@ /* visionA Local — splash screen */ * { margin: 0; padding: 0; box-sizing: border-box; } +:root { + --brand-bg-top: #1A1F36; + --brand-bg-bottom: #0E1222; + --brand-primary: #4F7EFF; + --brand-primary-light: #6EA8FF; + --brand-mint: #6EF3C5; + --brand-white: #FFFFFF; + --brand-muted: #8890B0; + --brand-danger: #FF6B6B; +} + html, body { height: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei', sans-serif; - background: #0b1020; - color: #e6e8f0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei', + 'PingFang TC', 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(180deg, var(--brand-bg-top) 0%, var(--brand-bg-bottom) 100%); + color: #E6E8F0; -webkit-font-smoothing: antialiased; + overflow: hidden; } #app { @@ -20,24 +33,60 @@ html, body { display: flex; flex-direction: column; align-items: center; - gap: 24px; + gap: 20px; padding: 48px; + animation: fadeIn 0.4s ease-out; } -.logo { +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.logo-icon { + width: 96px; + height: 96px; + border-radius: 22px; + box-shadow: 0 16px 64px rgba(79, 126, 255, 0.25), + 0 0 0 1px rgba(110, 168, 255, 0.12); +} + +.brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin-top: 4px; +} + +.brand-name { font-size: 28px; font-weight: 600; - letter-spacing: 0.02em; - color: #ffffff; + letter-spacing: -0.01em; + color: var(--brand-white); +} + +.brand-accent { + color: var(--brand-primary-light); + font-weight: 400; +} + +.brand-tagline { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--brand-muted); } .spinner { - width: 42px; - height: 42px; - border: 3px solid rgba(255, 255, 255, 0.12); - border-top-color: #6ea8ff; + width: 32px; + height: 32px; + border: 2.5px solid rgba(255, 255, 255, 0.08); + border-top-color: var(--brand-primary-light); border-radius: 50%; animation: spin 0.9s linear infinite; + margin-top: 16px; } @keyframes spin { @@ -45,18 +94,19 @@ html, body { } .status { - font-size: 14px; - color: #a7b0c6; + font-size: 13px; + color: var(--brand-muted); + letter-spacing: 0.02em; } .error { max-width: 560px; - padding: 16px 20px; - background: rgba(220, 38, 38, 0.12); - border: 1px solid rgba(220, 38, 38, 0.4); - border-radius: 8px; - color: #fca5a5; + padding: 14px 18px; + background: rgba(255, 107, 107, 0.1); + border: 1px solid rgba(255, 107, 107, 0.35); + border-radius: 10px; + color: #FFB5B5; font-size: 13px; - line-height: 1.5; + line-height: 1.55; text-align: center; } diff --git a/local-tool/visiona-local/main.go b/local-tool/visiona-local/main.go index a063c36..4bb5251 100644 --- a/local-tool/visiona-local/main.go +++ b/local-tool/visiona-local/main.go @@ -15,7 +15,7 @@ func main() { app := NewApp() err := wails.Run(&options.App{ - Title: "visionA Local", + Title: "visionA Local — Edge AI Workspace", Width: 1280, Height: 800, MinWidth: 960, diff --git a/local-tool/visiona-local/wails.json b/local-tool/visiona-local/wails.json index e239979..f183b38 100644 --- a/local-tool/visiona-local/wails.json +++ b/local-tool/visiona-local/wails.json @@ -13,8 +13,9 @@ }, "info": { "companyName": "Innovedus", - "productName": "visionA-local", + "productName": "visionA Local", "productVersion": "0.1.0", - "copyright": "Copyright 2026 Innovedus" + "copyright": "Copyright 2026 Innovedus", + "comments": "Edge AI Workspace for Kneron devices" } }