Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### 修复

- Windows 知识库本地文件夹挂接:选择器与列表预览按 UTF-8 处理中文路径/文件名,避免 `项目…` 这类乱码;挂接与列表预览不再因向量模型未就绪返回 409。
- 模型页注册本机 GGUF / 已拉取的 Ollama 模型时,Windows 桌面不再返回笼统的 `INTERNAL_ERROR` 500;`ollama create` 使用带引号的 POSIX `FROM` 路径,并把真实失败原因(文件不存在、权限、create 失败)返回给界面。

### 变更
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@
"KNOWLEDGE_UNSUPPORTED_TYPE": "This document type is not supported for knowledge bases.",
"KNOWLEDGE_NAME_TAKEN": "You already have a knowledge base with this name.",
"KNOWLEDGE_NAME_INVALID": "Invalid name.",
"KNOWLEDGE_MOUNT_INVALID": "The knowledge-base folder path is invalid or the directory does not exist.",
"AVATAR_INVALID": "Avatar must be a PNG, JPEG, WebP, or GIF image.",
"AVATAR_TOO_LARGE": "Avatar exceeds the 5 MB size limit.",
"INVITE_INVALID": "This invite code is invalid.",
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@
"KNOWLEDGE_UNSUPPORTED_TYPE": "知识库不支持此文档类型。",
"KNOWLEDGE_NAME_TAKEN": "您已有同名知识库。",
"KNOWLEDGE_NAME_INVALID": "名称无效。",
"KNOWLEDGE_MOUNT_INVALID": "知识库文件夹路径无效,或目录不存在。",
"AVATAR_INVALID": "头像须为 PNG、JPEG、WebP 或 GIF 图片。",
"AVATAR_TOO_LARGE": "头像超过 5 MB 大小限制。",
"INVITE_INVALID": "邀请码无效。",
Expand Down
26 changes: 24 additions & 2 deletions dashboard/src/utils/desktopFolder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,35 @@ describe("desktopFolder", () => {
removeEventListener: window.removeEventListener.bind(window),
} as unknown as Window & { _wails?: { invoke?: (m: string) => void } };
const path = await pickDesktopFolder(win, 1000);
expect(invoke).toHaveBeenCalledWith("wails:event:emit:desktop:select-folder");
expect(invoke).toHaveBeenCalledWith(
"wails:event:emit:desktop:select-folder",
);
expect(path).toBe("D:\\Projects\\demo");
});

it("repairs UTF-8 Chinese paths that arrived as Windows-1252 mojibake", async () => {
const bytes = new TextEncoder().encode("D:\\项目");
const garbled = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
const invoke = vi.fn(() => {
window.dispatchEvent(
new CustomEvent("freeos-folder-selected", { detail: garbled }),
);
});
const win = {
_wails: { invoke },
addEventListener: window.addEventListener.bind(window),
removeEventListener: window.removeEventListener.bind(window),
} as unknown as Window & { _wails?: { invoke?: (m: string) => void } };
await expect(pickDesktopFolder(win, 1000)).resolves.toBe("D:\\项目");
});

it("returns null when the user cancels", async () => {
const invoke = vi.fn(() => {
window.dispatchEvent(new CustomEvent("freeos-folder-selected", { detail: "" }));
window.dispatchEvent(
new CustomEvent("freeos-folder-selected", { detail: "" }),
);
});
const win = {
_wails: { invoke },
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/utils/desktopFolder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isDesktopShell } from "./desktopChrome";
import { repairUtf8Mojibake } from "./utf8Mojibake";

type DesktopWindow = Window & {
_wails?: { invoke?: (message: string) => void };
Expand Down Expand Up @@ -29,7 +30,11 @@ export function pickDesktopFolder(
};
const onSelected = (event: Event) => {
const detail = (event as CustomEvent<unknown>).detail;
finish(typeof detail === "string" && detail.trim() ? detail.trim() : null);
finish(
typeof detail === "string" && detail.trim()
? repairUtf8Mojibake(detail.trim())
: null,
);
};
const timer = window.setTimeout(() => finish(null), timeoutMs);
win.addEventListener(FOLDER_EVENT, onSelected as EventListener);
Expand Down
18 changes: 18 additions & 0 deletions dashboard/src/utils/utf8Mojibake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { repairUtf8Mojibake } from "./utf8Mojibake";

describe("repairUtf8Mojibake", () => {
it("restores UTF-8 Chinese that was decoded as Windows-1252", () => {
const bytes = new TextEncoder().encode("项目结构.png");
const garbled = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
);
expect(garbled).not.toBe("项目结构.png");
expect(repairUtf8Mojibake(garbled)).toBe("项目结构.png");
});

it("leaves already-valid Unicode and ASCII alone", () => {
expect(repairUtf8Mojibake("项目结构.png")).toBe("项目结构.png");
expect(repairUtf8Mojibake("D:\\docs\\notes")).toBe("D:\\docs\\notes");
});
});
55 changes: 55 additions & 0 deletions dashboard/src/utils/utf8Mojibake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/** Map Unicode code points that CP1252 uses for bytes 0x80–0x9F back to those bytes. */
const CP1252_REVERSE: Record<number, number> = {
0x20ac: 0x80,
0x201a: 0x82,
0x0192: 0x83,
0x201e: 0x84,
0x2026: 0x85,
0x2020: 0x86,
0x2021: 0x87,
0x02c6: 0x88,
0x2030: 0x89,
0x0160: 0x8a,
0x2039: 0x8b,
0x0152: 0x8c,
0x017d: 0x8e,
0x2018: 0x91,
0x2019: 0x92,
0x201c: 0x93,
0x201d: 0x94,
0x2022: 0x95,
0x2013: 0x96,
0x2014: 0x97,
0x02dc: 0x98,
0x2122: 0x99,
0x0161: 0x9a,
0x203a: 0x9b,
0x0153: 0x9c,
0x017e: 0x9e,
0x0178: 0x9f,
};

function cp1252Byte(code: number): number | null {
if (code <= 0xff) return code;
return CP1252_REVERSE[code] ?? null;
}

/**
* Repair UTF-8 text that a Windows bridge exposed as Latin-1 / CP1252.
* Example: ``项目结构.png`` → ``项目结构.png``.
*/
export function repairUtf8Mojibake(text: string): string {
if (!text) return text;
const bytes = new Uint8Array(text.length);
for (let i = 0; i < text.length; i += 1) {
const mapped = cp1252Byte(text.charCodeAt(i));
if (mapped === null) return text;
bytes[i] = mapped;
}
try {
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return decoded || text;
} catch {
return text;
}
}
2 changes: 2 additions & 0 deletions desktop/portable/templates/start.bat
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ if not exist "%ROOT%\launch.py" (

rem Prefer launch.py (site.addsitedir + pywin32 DLL path). Do not set PYTHONPATH.
set "PYTHONNOUSERSITE=1"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
set "PYTHONPATH="

if exist "%ROOT%\org-sidecar\start-sidecar.bat" (
Expand Down
2 changes: 2 additions & 0 deletions desktop/portable/templates/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ fi

# launch.py adds packages/ via site.addsitedir (honours .pth / pywin32).
export PYTHONNOUSERSITE=1
export PYTHONUTF8=1
export PYTHONIOENCODING=utf-8
unset PYTHONPATH || true

if [[ -x "${ROOT}/org-sidecar/start-sidecar.sh" ]]; then
Expand Down
146 changes: 142 additions & 4 deletions desktop/src/folder_dialog.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package main

import (
"encoding/base64"
"fmt"
"log"
"os/exec"
"runtime"
"strings"
"unicode/utf16"
"unicode/utf8"
)

func (a *App) emitPickedFolder() {
Expand Down Expand Up @@ -42,6 +45,8 @@ func pickFolderNative() (string, error) {

func pickFolderWindows() (string, error) {
// FolderBrowserDialog with MyComputer so every drive letter is reachable.
// SelectedPath is a .NET Unicode string; emit UTF-8 as Base64 so the
// hidden-window PowerShell console code page cannot mojibake CJK.
script := strings.Join(
[]string{
"Add-Type -AssemblyName System.Windows.Forms",
Expand All @@ -50,7 +55,9 @@ func pickFolderWindows() (string, error) {
"$d.Description = 'Select a work folder'",
"$d.RootFolder = [System.Environment+SpecialFolder]::MyComputer",
"$d.ShowNewFolderButton = $true",
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }",
"if ($d.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { exit 0 }",
"$bytes = [System.Text.Encoding]::UTF8.GetBytes($d.SelectedPath)",
"[Convert]::ToBase64String($bytes)",
},
"; ",
)
Expand All @@ -67,11 +74,142 @@ func pickFolderWindows() (string, error) {
if err != nil {
return "", fmt.Errorf("folder dialog: %w", err)
}
path := strings.TrimSpace(string(out))
if path == "" {
return decodeFolderPickerOutput(out)
}

func decodeFolderPickerOutput(out []byte) (string, error) {
raw := strings.TrimSpace(normalizePickerBytes(out))
if raw == "" {
return "", nil
}
return path, nil
if i := strings.LastIndex(raw, "\n"); i >= 0 {
if last := strings.TrimSpace(raw[i+1:]); last != "" {
raw = last
}
}
if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil && len(decoded) > 0 && utf8.Valid(decoded) {
return string(decoded), nil
}
return repairUTF8Mojibake(raw), nil
}

func normalizePickerBytes(out []byte) string {
if len(out) >= 2 && out[0] == 0xFF && out[1] == 0xFE {
return utf16LEToString(out[2:])
}
if looksLikeUTF16LE(out) {
return utf16LEToString(out)
}
return string(out)
}

func looksLikeUTF16LE(b []byte) bool {
if len(b) < 4 || len(b)%2 != 0 {
return false
}
zeros := 0
pairs := 0
for i := 1; i < len(b); i += 2 {
pairs++
if b[i] == 0 {
zeros++
}
}
return pairs > 0 && zeros*2 >= pairs
}

func utf16LEToString(b []byte) string {
if len(b)%2 == 1 {
b = b[:len(b)-1]
}
u := make([]uint16, 0, len(b)/2)
for i := 0; i+1 < len(b); i += 2 {
u = append(u, uint16(b[i])|uint16(b[i+1])<<8)
}
return string(utf16.Decode(u))
}

func repairUTF8Mojibake(text string) string {
raw := make([]byte, 0, len(text))
for _, r := range text {
if mapped, ok := cp1252Byte(r); ok {
raw = append(raw, mapped)
continue
}
return text
}
if !utf8.Valid(raw) {
return text
}
repaired := string(raw)
if repaired != text {
return repaired
}
return text
}

func cp1252Byte(r rune) (byte, bool) {
if r <= 0xFF {
return byte(r), true
}
switch r {
case 0x20AC:
return 0x80, true
case 0x201A:
return 0x82, true
case 0x0192:
return 0x83, true
case 0x201E:
return 0x84, true
case 0x2026:
return 0x85, true
case 0x2020:
return 0x86, true
case 0x2021:
return 0x87, true
case 0x02C6:
return 0x88, true
case 0x2030:
return 0x89, true
case 0x0160:
return 0x8A, true
case 0x2039:
return 0x8B, true
case 0x0152:
return 0x8C, true
case 0x017D:
return 0x8E, true
case 0x2018:
return 0x91, true
case 0x2019:
return 0x92, true
case 0x201C:
return 0x93, true
case 0x201D:
return 0x94, true
case 0x2022:
return 0x95, true
case 0x2013:
return 0x96, true
case 0x2014:
return 0x97, true
case 0x02DC:
return 0x98, true
case 0x2122:
return 0x99, true
case 0x0161:
return 0x9A, true
case 0x203A:
return 0x9B, true
case 0x0153:
return 0x9C, true
case 0x017E:
return 0x9E, true
case 0x0178:
return 0x9F, true
default:
return 0, false
}
}

func pickFolderDarwin() (string, error) {
Expand Down
Loading
Loading