feat(local-tool): 品牌視覺設計 + 內建模型首次啟動 seed

#8 首次啟動 seed 內建模型:
- app.go 新增 seedUserDataDir() 在 server spawn 之前執行
- 若 user data-dir 缺 models.json,從 locateBundleDataDir() 複製
  models.json + nef/ 預置模型過去
- 新增 locateBundleDataDir() / copyFile() / copyDirRecursive() helper
- user 第一次開 app 會看到 8 個 Kneron 預置 .nef 模型(kl520×5 + kl720×3)

#5 #6 #7 品牌視覺:
- 新增 branding/ 目錄存放設計資產與生成工具
  - logo.svg(向量原始稿)
  - icon-{16,...,1024}.png(10 種尺寸)
  - icon.ico(Windows 多解析度 ICO,PNG-in-ICO 格式)
  - icon.icns(macOS)
  - tools/gen_icon.go + gen_ico.go(純 Go 生成工具,未來調整 logo 用)
  - README.md + 色票表
- 部署:
  - visiona-local/build/appicon.png → Wails build 會嵌入 exe
  - visiona-local/frontend/icon.png → splash 使用
  - frontend/src/app/favicon.ico + icon.png → Next.js App Router favicon
- splash page 升級:加 logo icon + 品牌名 visionA Local + tagline Edge AI Workspace
- Wails window title: "visionA Local — Edge AI Workspace"
- wails.json productName: "visionA Local"
- Next.js metadata title + icons
- i18n: en/zh-TW 把殘留的 "Edge AI 平台" 字串改為 visionA Local 品牌
- .iss: SetupIconFile 指向 branding/icon.ico + UninstallDisplayIcon +
  ArchitecturesAllowed 改 x64compatible 修掉之前的 deprecation warning

品牌色票:
- 主色 #4F7EFF(電子藍)
- 輔色 #6EF3C5(mint 點綴)
- 深色背景漸層 #1A1F36 → #0E1222
- 警示 #FF6B6B

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-12 04:42:41 +08:00
parent 8e7b6ae435
commit 50a3f73acd
23 changed files with 741 additions and 40 deletions

View File

@ -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 多解析度 ICO16/24/32/48/64/96/128/256PNG-in-ICO 格式) |
| `icon.icns` | macOS .app bundle icon |
## 如何更新 logo
1. 改 `logo.svg` 或改 `gen_icon.go` 的繪圖函數
2. 跑 `go run gen_icon.go <output-dir>` 產出各尺寸 PNG
3. 跑 `go run gen_ico.go icon.ico <png-dir> 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` |

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- visionA Local — Logo -->
<!--
設計理念:
- 外層 rounded square 作為 app icon 的標準容器
- 深色背景 (#1A1F36) 襯托鏡頭感
- 同心圓 = 相機鏡頭 / 視覺感測器
- 中央幾何 "V" (vision 的縮寫) 用電子藍點陣強調「AI / edge」感
- 一點 mint 點綴用來打破純藍的冰冷
-->
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1A1F36"/>
<stop offset="100%" stop-color="#0E1222"/>
</linearGradient>
<linearGradient id="lensGrad" x1="30%" y1="20%" x2="70%" y2="80%">
<stop offset="0%" stop-color="#6EA8FF"/>
<stop offset="100%" stop-color="#4F7EFF"/>
</linearGradient>
<radialGradient id="centerGrad" cx="50%" cy="45%" r="60%">
<stop offset="0%" stop-color="#6EF3C5" stop-opacity="0.9"/>
<stop offset="60%" stop-color="#4F7EFF" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#4F7EFF" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- 背景圓角方框 -->
<rect x="0" y="0" width="1024" height="1024" rx="220" ry="220" fill="url(#bgGrad)"/>
<!-- 外層鏡頭圓環(最粗) -->
<circle cx="512" cy="512" r="360" fill="none" stroke="url(#lensGrad)" stroke-width="36"/>
<!-- 中層鏡頭圓環(細) -->
<circle cx="512" cy="512" r="296" fill="none" stroke="#4F7EFF" stroke-width="14" stroke-opacity="0.6"/>
<!-- 內層光暈(中央感測器意象) -->
<circle cx="512" cy="512" r="240" fill="url(#centerGrad)"/>
<!-- 中央 "V" 幾何標誌vision -->
<!-- 用兩條粗線組成,線條頂端有 pixel dot底部交會 -->
<g stroke="#FFFFFF" stroke-width="44" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="M 360 360 L 512 640"/>
<path d="M 664 360 L 512 640"/>
</g>
<!-- Pixel accent dots三個像素點強化 edge AI 的概念) -->
<circle cx="360" cy="360" r="22" fill="#6EF3C5"/>
<circle cx="664" cy="360" r="22" fill="#6EA8FF"/>
<circle cx="512" cy="640" r="26" fill="#FFFFFF"/>
<!-- 右上角 active indicator科技感點綴 -->
<circle cx="760" cy="264" r="14" fill="#6EF3C5" opacity="0.85"/>
<circle cx="760" cy="264" r="24" fill="none" stroke="#6EF3C5" stroke-width="3" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,95 @@
// 把多張 PNG 打包成 Windows .icoPNG-in-ICO 格式Vista+ 支援)
// 用法go run gen_ico.go <output.ico> <png-dir> <sizes-csv>
// 範例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 <output.ico> <png-dir> <sizes-csv>")
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")
}

View File

@ -0,0 +1,289 @@
// 一次性 icon 生成工具 — 產 visionA Local logo 的多種解析度 PNG
// 用法go run gen_icon.go <output-dir>
//
// 此檔案為 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 <output-dir>")
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)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -20,8 +20,14 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "visionA-local", title: "visionA Local — Edge AI Workspace",
description: "Local-first Edge AI development tool", 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({ export default function RootLayout({

View File

@ -29,9 +29,9 @@ export const en: TranslationDict = {
devices: 'Devices', devices: 'Devices',
workspace: 'Workspace', workspace: 'Workspace',
settings: 'Settings', settings: 'Settings',
appName: 'visionA', appName: 'visionA Local',
version: 'visionA local v0.1.0', version: 'visionA Local v0.1.0',
platformTitle: 'visionA Local Edge AI', platformTitle: 'visionA Local Edge AI Workspace',
serverConnected: 'Server Connected', serverConnected: 'Server Connected',
serverDisconnected: 'Server Disconnected', serverDisconnected: 'Server Disconnected',
}, },
@ -44,7 +44,7 @@ export const en: TranslationDict = {
}, },
dashboard: { dashboard: {
title: 'Dashboard', title: 'Dashboard',
subtitle: 'Overview of your Edge AI platform', subtitle: 'Overview of your visionA workspace',
models: 'Models', models: 'Models',
devices: 'Devices', devices: 'Devices',
connected: 'Connected', connected: 'Connected',
@ -319,7 +319,7 @@ export const en: TranslationDict = {
languageEn: 'English', languageEn: 'English',
about: 'About', about: 'About',
versionLabel: 'Version', versionLabel: 'Version',
platform: 'Edge AI Development Platform', platform: 'visionA Local — Edge AI Workspace',
resetToDefaults: 'Reset to Defaults', resetToDefaults: 'Reset to Defaults',
resetSuccess: 'Settings restored to defaults', resetSuccess: 'Settings restored to defaults',
serverLogs: { serverLogs: {
@ -360,7 +360,7 @@ export const en: TranslationDict = {
}, },
}, },
onboarding: { onboarding: {
welcome: 'Welcome to Edge AI Platform!', welcome: 'Welcome to visionA Local!',
step1Title: 'Connect Your Device', step1Title: 'Connect Your Device',
step1Desc: 'Plug in your Kneron USB Dongle and scan for devices. The platform will automatically detect supported hardware.', step1Desc: 'Plug in your Kneron USB Dongle and scan for devices. The platform will automatically detect supported hardware.',
step2Title: 'Choose an AI Model', step2Title: 'Choose an AI Model',

View File

@ -29,9 +29,9 @@ export const zhTW: TranslationDict = {
devices: '裝置', devices: '裝置',
workspace: '工作區', workspace: '工作區',
settings: '設定', settings: '設定',
appName: 'visionA', appName: 'visionA Local',
version: 'visionA local v0.1.0', version: 'visionA Local v0.1.0',
platformTitle: 'visionA — 本機 Edge AI', platformTitle: 'visionA Local — 邊緣 AI 工作站',
serverConnected: '伺服器已連線', serverConnected: '伺服器已連線',
serverDisconnected: '伺服器未連線', serverDisconnected: '伺服器未連線',
}, },
@ -44,7 +44,7 @@ export const zhTW: TranslationDict = {
}, },
dashboard: { dashboard: {
title: '儀表板', title: '儀表板',
subtitle: 'Edge AI 平台總覽', subtitle: 'visionA 工作站總覽',
models: '模型', models: '模型',
devices: '裝置', devices: '裝置',
connected: '已連線', connected: '已連線',
@ -319,7 +319,7 @@ export const zhTW: TranslationDict = {
languageEn: 'English', languageEn: 'English',
about: '關於', about: '關於',
versionLabel: '版本', versionLabel: '版本',
platform: 'Edge AI 開發平台', platform: 'visionA Local — 邊緣 AI 工作站',
resetToDefaults: '恢復預設值', resetToDefaults: '恢復預設值',
resetSuccess: '設定已恢復預設值', resetSuccess: '設定已恢復預設值',
serverLogs: { serverLogs: {
@ -360,7 +360,7 @@ export const zhTW: TranslationDict = {
}, },
}, },
onboarding: { onboarding: {
welcome: '歡迎使用 Edge AI 平台', welcome: '歡迎使用 visionA Local',
step1Title: '連接你的裝置', step1Title: '連接你的裝置',
step1Desc: '插入 Kneron USB Dongle 並掃描裝置。平台將自動偵測支援的硬體。', step1Desc: '插入 Kneron USB Dongle 並掃描裝置。平台將自動偵測支援的硬體。',
step2Title: '選擇 AI 模型', step2Title: '選擇 AI 模型',

View File

@ -13,11 +13,12 @@
; 所有路徑相對於本 .iss 所在目錄 (installer/windows/) ; 所有路徑相對於本 .iss 所在目錄 (installer/windows/)
; 因此 ..\.. 會指回專案根目錄 (local-tool/) ; 因此 ..\.. 會指回專案根目錄 (local-tool/)
#define MyAppName "visionA-local" #define MyAppName "visionA Local"
#define MyAppVersion "0.1.0" #define MyAppVersion "0.1.0"
#define MyAppPublisher "Innovedus" #define MyAppPublisher "Innovedus"
#define MyAppURL "https://github.com/Innovedus/visiona-local" #define MyAppURL "https://github.com/Innovedus/visiona-local"
#define MyAppExeName "visiona-local.exe" #define MyAppExeName "visiona-local.exe"
#define MyAppIcon "..\..\branding\icon.ico"
[Setup] [Setup]
; AppId 固定 GUID產品識別用未來升級時不可更動 ; AppId 固定 GUID產品識別用未來升級時不可更動
@ -39,8 +40,12 @@ OutputBaseFilename=visiona-local-{#MyAppVersion}-windows-x64
Compression=lzma2/ultra Compression=lzma2/ultra
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
ArchitecturesAllowed=x64 ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64 ArchitecturesInstallIn64BitMode=x64compatible
; Installer 自身 icon
SetupIconFile={#MyAppIcon}
; 解除安裝程式在「設定 > 應用程式」顯示的 icon
UninstallDisplayIcon={app}\{#MyAppExeName}
; Windows 10 1809 以上WinUSB / pnputil 需求) ; Windows 10 1809 以上WinUSB / pnputil 需求)
MinVersion=10.0.17763 MinVersion=10.0.17763
; 不需要 code signing使用者決策 Q2=C ; 不需要 code signing使用者決策 Q2=C

View File

@ -172,6 +172,13 @@ func (a *App) startup(ctx context.Context) {
fmt.Fprintln(os.Stderr, "[visiona-local] IPC server start failed:", err) 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=<user> 情境下讀到內建模型。
// 失敗不擋啟動,只是 server 啟動後模型庫會是空的。
if err := a.seedUserDataDir(); err != nil {
fmt.Fprintln(os.Stderr, "[visiona-local] seed user data dir failed:", err)
}
// 4. 啟動 server 子行程 // 4. 啟動 server 子行程
if err := a.startServer(); err != nil { if err := a.startServer(); err != nil {
a.reportFatal("server start failed", err) a.reportFatal("server start failed", err)
@ -771,6 +778,134 @@ func locateBundleBinDir() (string, error) {
return "", fmt.Errorf("bundle bin dir not found") return "", fmt.Errorf("bundle bin dir not found")
} }
// locateBundleDataDir 找 installer 打包的 data 目錄(含 models.json + nef/ 預置模型)。
//
// 順序:
// 1. macOS .app bundleContents/Resources/data
// 2. Windows / Linux 打包後:與 Wails 執行檔同目錄下的 data/
// 3. 開發模式:<repo>/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/ fallbackWindows / Linux Inno Setup / AppImage 佈局)
d := filepath.Join(exeDir, "data")
if dirExists(d) {
abs, _ := filepath.Abs(d)
return abs, nil
}
}
// 開發模式 fallback<repo>/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=<user> 情境下讀到內建模型。
//
// 只複製缺失的檔案,不覆蓋使用者已有的資料。
//
// 複製清單(相對於 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 { func fileExists(p string) bool {
info, err := os.Stat(p) info, err := os.Stat(p)
return err == nil && !info.IsDir() return err == nil && !info.IsDir()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -4,12 +4,17 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>visionA Local</title> <title>visionA Local</title>
<link rel="icon" type="image/png" href="icon.png">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<div class="splash"> <div class="splash">
<div class="logo">visionA Local</div> <img class="logo-icon" src="icon.png" alt="visionA Local">
<div class="brand">
<div class="brand-name">visionA <span class="brand-accent">Local</span></div>
<div class="brand-tagline">Edge AI Workspace</div>
</div>
<div class="spinner"></div> <div class="spinner"></div>
<div class="status" id="status">正在啟動伺服器...</div> <div class="status" id="status">正在啟動伺服器...</div>
<div class="error" id="error" hidden></div> <div class="error" id="error" hidden></div>

View File

@ -1,12 +1,25 @@
/* visionA Local — splash screen */ /* visionA Local — splash screen */
* { margin: 0; padding: 0; box-sizing: border-box; } * { 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 { html, body {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei',
background: #0b1020; 'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
color: #e6e8f0; background: linear-gradient(180deg, var(--brand-bg-top) 0%, var(--brand-bg-bottom) 100%);
color: #E6E8F0;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow: hidden;
} }
#app { #app {
@ -20,24 +33,60 @@ html, body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 24px; gap: 20px;
padding: 48px; 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-size: 28px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: -0.01em;
color: #ffffff; 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 { .spinner {
width: 42px; width: 32px;
height: 42px; height: 32px;
border: 3px solid rgba(255, 255, 255, 0.12); border: 2.5px solid rgba(255, 255, 255, 0.08);
border-top-color: #6ea8ff; border-top-color: var(--brand-primary-light);
border-radius: 50%; border-radius: 50%;
animation: spin 0.9s linear infinite; animation: spin 0.9s linear infinite;
margin-top: 16px;
} }
@keyframes spin { @keyframes spin {
@ -45,18 +94,19 @@ html, body {
} }
.status { .status {
font-size: 14px; font-size: 13px;
color: #a7b0c6; color: var(--brand-muted);
letter-spacing: 0.02em;
} }
.error { .error {
max-width: 560px; max-width: 560px;
padding: 16px 20px; padding: 14px 18px;
background: rgba(220, 38, 38, 0.12); background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(220, 38, 38, 0.4); border: 1px solid rgba(255, 107, 107, 0.35);
border-radius: 8px; border-radius: 10px;
color: #fca5a5; color: #FFB5B5;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.55;
text-align: center; text-align: center;
} }

View File

@ -15,7 +15,7 @@ func main() {
app := NewApp() app := NewApp()
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "visionA Local", Title: "visionA Local — Edge AI Workspace",
Width: 1280, Width: 1280,
Height: 800, Height: 800,
MinWidth: 960, MinWidth: 960,

View File

@ -13,8 +13,9 @@
}, },
"info": { "info": {
"companyName": "Innovedus", "companyName": "Innovedus",
"productName": "visionA-local", "productName": "visionA Local",
"productVersion": "0.1.0", "productVersion": "0.1.0",
"copyright": "Copyright 2026 Innovedus" "copyright": "Copyright 2026 Innovedus",
"comments": "Edge AI Workspace for Kneron devices"
} }
} }