diff --git a/.env.lite.example b/.env.lite.example
index cfdcb56b..119a3d67 100644
--- a/.env.lite.example
+++ b/.env.lite.example
@@ -2,9 +2,18 @@
# 复制此文件为 .env.lite 并按需修改
# cp .env.lite.example .env.lite
-GIN_MODE=release
+GIN_MODE=debug
+
+# 日志级别,可选值:debug, info, warn, error, fatal
+LOG_LEVEL=debug
+
+# 可选:日志文件绝对路径;未配置时,macOS .app 默认写入
+# ~/Library/Logs/WeKnora Lite/WeKnora Lite.log
+# LOG_PATH=/absolute/path/to/weknora-lite.log
# === 数据库 ===
+# macOS .app 中相对路径会自动重定向到
+# ~/Library/Application Support/WeKnora Lite/data/
DB_DRIVER=sqlite
DB_PATH=./data/weknora.db
diff --git a/.gitignore b/.gitignore
index d778d957..c5241632 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ logs/
WeKnora
WeKnora-lite
+cmd/desktop/build/bin/
/models/
test/data/mswag.txt
data/files/
diff --git a/cmd/desktop/build/appicon.png b/cmd/desktop/build/appicon.png
new file mode 100644
index 00000000..63617fe4
Binary files /dev/null and b/cmd/desktop/build/appicon.png differ
diff --git a/cmd/desktop/build/darwin/Info.plist b/cmd/desktop/build/darwin/Info.plist
new file mode 100644
index 00000000..d17a7475
--- /dev/null
+++ b/cmd/desktop/build/darwin/Info.plist
@@ -0,0 +1,63 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ com.wails.{{.Name}}
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.13.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+
+
diff --git a/cmd/desktop/main.go b/cmd/desktop/main.go
index b529d525..95012e7a 100644
--- a/cmd/desktop/main.go
+++ b/cmd/desktop/main.go
@@ -33,6 +33,73 @@ import (
"golang.org/x/sys/unix"
)
+// dragHandlerJS is injected into the webview on DomReady.
+// It bypasses Wails' built-in CSS-variable-based drag detection (which uses
+// getComputedStyle and has timing/inheritance issues with dynamic SPA content)
+// and instead uses robust DOM-traversal via el.closest() plus a Y-position
+// fallback for the macOS title-bar region on layout containers. The "drag"
+// message is sent directly through the WKWebView script-message bridge,
+// which the native Objective-C handler in WailsContext.m converts to
+// [NSWindow performWindowDragWithEvent:].
+const dragHandlerJS = `(function(){
+if(window.__wkDragBound)return;
+window.__wkDragBound=true;
+
+if(window.wails&&window.wails.flags){
+ window.wails.flags.cssDragProperty='__disabled__';
+ window.wails.flags.cssDragValue='__never__';
+}
+
+var TITLEBAR_H=38;
+
+var dragSel='.logo_row,.menu_top,.header,.header-title,.title-row,' +
+ '.dialogue-title,.section-header,.dialog-header,.sidebar-header,' +
+ '.document-header,.drag-region,[data-wails-drag]';
+
+var noDragSel='button,a,input,select,textarea,[role="button"],' +
+ '.t-button,.t-input,.t-select,.t-textarea,' +
+ '.header-actions,.header-action-btn,.sidebar-toggle,.logo_box,' +
+ '.close-btn,.menu_item,.submenu,.submenu_item,.menu_bottom,' +
+ '.t-popup,.t-dropdown,.t-tooltip,.t-dialog,[data-no-drag]';
+
+var layoutClasses=['main','chat','dialogue-wrap','kb-list-container',
+ 'agent-list-container','org-list-container','aside_box',
+ 'ks-container','settings-overlay','knowledge-layout',
+ 'faq-manager-wrapper','login-layout'];
+
+function sendDrag(){
+ try{window.webkit.messageHandlers.external.postMessage('drag')}
+ catch(_){try{window.WailsInvoke('drag')}catch(e){}}
+}
+
+function isLayoutEl(el){
+ for(var i=0;i {
- return noDragSelectors.some((selector) => el.closest(selector));
- };
-
- const findDragTarget = (el) => {
- for (const selector of dragSelectors) {
- const match = el.closest(selector);
- if (match) return match;
- }
- return null;
- };
-
- const applyDragMarkers = () => {
- for (const selector of dragSelectors) {
- document.querySelectorAll(selector).forEach((root) => {
- if (!(root instanceof HTMLElement)) return;
- root.style.setProperty('--wails-draggable', 'drag');
- root.querySelectorAll('*').forEach((child) => {
- if (!(child instanceof HTMLElement)) return;
- child.style.setProperty('--wails-draggable', 'drag');
- });
- });
- }
-
- for (const selector of noDragSelectors) {
- document.querySelectorAll(selector).forEach((node) => {
- if (!(node instanceof HTMLElement)) return;
- node.style.setProperty('--wails-draggable', 'no-drag');
- node.querySelectorAll('*').forEach((child) => {
- if (!(child instanceof HTMLElement)) return;
- child.style.setProperty('--wails-draggable', 'no-drag');
- });
- });
- }
- };
-
- applyDragMarkers();
- const observer = new MutationObserver(() => {
- applyDragMarkers();
- });
- observer.observe(document.body, {
- childList: true,
- subtree: true,
- attributes: false
- });
-
- document.addEventListener('mousedown', (e) => {
- if (e.button !== 0 || e.detail !== 1) return;
- let target = e.target;
- if (target instanceof Text) {
- target = target.parentElement;
- }
- if (!(target instanceof Element)) return;
- if (isNoDrag(target)) return;
- const dragTarget = findDragTarget(target);
- if (!dragTarget) return;
- e.preventDefault();
- if (typeof window.WailsInvoke === 'function') {
- window.WailsInvoke('drag');
- }
- }, true);
- })();
- `
- wailsruntime.WindowExecJS(ctx, css)
+ wailsruntime.WindowExecJS(ctx, dragHandlerJS)
},
OnShutdown: app.shutdown,
Bind: []interface{}{
@@ -345,6 +282,83 @@ func (a *App) shutdown(ctx context.Context) {
a.shutdownCh <- struct{}{}
}
+func configureDesktopStorage(execPath string) {
+ if execPath == "" || !strings.Contains(execPath, ".app/Contents/MacOS") {
+ return
+ }
+
+ appSupportDir, err := defaultMacAppSupportDir(execPath)
+ if err != nil {
+ logger.Warnf(context.Background(), "Failed to resolve app support dir: %v", err)
+ return
+ }
+
+ legacyResourcesDir := filepath.Join(filepath.Dir(filepath.Dir(execPath)), "Resources")
+ targetDataDir := filepath.Join(appSupportDir, "data")
+ migrateLegacyDesktopData(legacyResourcesDir, targetDataDir)
+
+ dbPath := resolveDesktopDataPath(os.Getenv("DB_PATH"), filepath.Join("data", "weknora.db"), appSupportDir)
+ filesPath := resolveDesktopDataPath(os.Getenv("LOCAL_STORAGE_BASE_DIR"), filepath.Join("data", "files"), appSupportDir)
+
+ if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
+ logger.Warnf(context.Background(), "Failed to create desktop DB directory %s: %v", filepath.Dir(dbPath), err)
+ }
+ if err := os.MkdirAll(filesPath, 0o755); err != nil {
+ logger.Warnf(context.Background(), "Failed to create desktop files directory %s: %v", filesPath, err)
+ }
+
+ _ = os.Setenv("DB_PATH", dbPath)
+ _ = os.Setenv("LOCAL_STORAGE_BASE_DIR", filesPath)
+}
+
+func defaultMacAppSupportDir(execPath string) (string, error) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+
+ appName := "WeKnora Lite"
+ if idx := strings.Index(execPath, ".app/Contents/MacOS"); idx >= 0 {
+ bundleName := filepath.Base(execPath[:idx+4])
+ if trimmed := strings.TrimSuffix(bundleName, ".app"); trimmed != "" {
+ appName = trimmed
+ }
+ }
+
+ return filepath.Join(homeDir, "Library", "Application Support", appName), nil
+}
+
+func resolveDesktopDataPath(rawPath, defaultRelativePath, appSupportDir string) string {
+ trimmed := strings.TrimSpace(rawPath)
+ if trimmed == "" {
+ trimmed = defaultRelativePath
+ }
+ if filepath.IsAbs(trimmed) {
+ return filepath.Clean(trimmed)
+ }
+ trimmed = strings.TrimPrefix(trimmed, "."+string(filepath.Separator))
+ return filepath.Join(appSupportDir, filepath.Clean(trimmed))
+}
+
+func migrateLegacyDesktopData(resourcesDir, targetDataDir string) {
+ legacyDataDir := filepath.Join(resourcesDir, "data")
+ if info, err := os.Stat(legacyDataDir); err != nil || !info.IsDir() {
+ return
+ }
+ if _, err := os.Stat(targetDataDir); err == nil {
+ return
+ }
+ if err := os.MkdirAll(filepath.Dir(targetDataDir), 0o755); err != nil {
+ logger.Warnf(context.Background(), "Failed to create app support parent dir %s: %v", filepath.Dir(targetDataDir), err)
+ return
+ }
+ if err := os.Rename(legacyDataDir, targetDataDir); err != nil {
+ logger.Warnf(context.Background(), "Failed to migrate legacy desktop data from %s to %s: %v", legacyDataDir, targetDataDir, err)
+ return
+ }
+ logger.Infof(context.Background(), "Migrated legacy desktop data to %s", targetDataDir)
+}
+
func listenWithRetry(addr string, maxRetries int, baseDelay time.Duration) (net.Listener, error) {
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7b782e78..7b64e27e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "knowledage-base",
- "version": "0.3.4",
+ "version": "0.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "knowledage-base",
- "version": "0.3.4",
+ "version": "0.3.6",
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@types/dompurify": "^3.0.5",
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
new file mode 100755
index 00000000..e994af2d
--- /dev/null
+++ b/frontend/package.json.md5
@@ -0,0 +1 @@
+a8c281c741c2499a02564295ccfb3f91
\ No newline at end of file
diff --git a/frontend/src/api/chat/streame.ts b/frontend/src/api/chat/streame.ts
index ec789d92..e1963b07 100644
--- a/frontend/src/api/chat/streame.ts
+++ b/frontend/src/api/chat/streame.ts
@@ -2,6 +2,7 @@ import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ref, type Ref, onUnmounted, nextTick } from 'vue'
import { generateRandomString } from '@/utils/index';
import i18n from '@/i18n';
+import { getApiBaseUrl } from '@/utils/api-base';
@@ -37,7 +38,7 @@ export function useStream() {
isLoading.value = true;
// 获取API配置
- const apiUrl = import.meta.env.VITE_IS_DOCKER ? "" : "http://localhost:8080";
+ const apiUrl = getApiBaseUrl();
// 获取JWT Token
const token = localStorage.getItem('weknora_token');
diff --git a/frontend/src/components/IMChannelPanel.vue b/frontend/src/components/IMChannelPanel.vue
index 787d7e9e..ea4e0597 100644
--- a/frontend/src/components/IMChannelPanel.vue
+++ b/frontend/src/components/IMChannelPanel.vue
@@ -419,7 +419,7 @@ async function loadChannels() {
}
function getCallbackUrl(channel: IMChannel): string {
- const base = import.meta.env.VITE_IS_DOCKER ? window.location.origin : 'http://127.0.0.1:8080';
+ const base = window.location.origin;
return `${base}/api/v1/im/callback/${channel.id}`;
}
diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts
index 5f99a453..82034586 100755
--- a/frontend/src/i18n/locales/en-US.ts
+++ b/frontend/src/i18n/locales/en-US.ts
@@ -3458,11 +3458,15 @@ export default {
untitled: 'Untitled',
resourceLoadFailed: 'Failed to load resources',
noResources: 'No wiki spaces found',
- noResourcesDesc: 'The app needs wiki access via a group chat to fetch content',
+ noResourcesDesc: 'The app needs wiki access via a group chat to fetch content',
+ noResourcesDesc_notion: 'The app needs Notion page access permissions to fetch content',
retryLoadResources: 'Retry',
guideStep1: 'Create a group chat in Feishu, then add your app as a bot in the group settings',
guideStep2: 'Open wiki "Settings" > "Member Settings" > "Add Member", search for the group chat and add it',
guideStep3: 'Ensure the group chat role is at least "Can Read", then come back and click Retry',
+ guideStep1_notion: 'Open the page or database you want to sync in Notion',
+ guideStep2_notion: 'Click the "···" menu at the top right, select "Connect to" or "Add connections"',
+ guideStep3_notion: 'Search and select your Integration app, then come back and click Retry',
permissionDocLink: 'View Feishu wiki permission docs',
syncScheduleLabel: 'Sync schedule',
conflictLabel: 'Conflict strategy',
diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts
index 596c7222..80d14db4 100755
--- a/frontend/src/i18n/locales/ko-KR.ts
+++ b/frontend/src/i18n/locales/ko-KR.ts
@@ -3510,11 +3510,15 @@ export default {
untitled: "제목 없음",
resourceLoadFailed: "리소스 목록 로드 실패",
noResources: "동기화 가능한 위키 공간을 찾을 수 없습니다",
- noResourcesHint: "먼저 데이터 소스를 저장하고 권한 설정을 확인하세요",
- permissionHint: "앱이 위키 공간의 멤버로 추가되어야 콘텐츠에 접근할 수 있습니다:",
- permissionStep1: "페이슈를 열고 동기화할 위키 공간으로 이동",
- permissionStep2: "오른쪽 상단 '...' > '설정' > '멤버 관리' 클릭",
- permissionStep3: "페이슈 앱을 검색하여 위키 공간 멤버로 추가",
+ noResourcesDesc: "앱이 콘텐츠를 가져오려면 그룹 채팅을 통해 위키 접근 권한을 얻어야 합니다",
+ noResourcesDesc_notion: "앱이 콘텐츠를 가져오려면 Notion 페이지 접근 권한이 필요합니다",
+ retryLoadResources: "다시 시도",
+ guideStep1: "Feishu에서 그룹 채팅을 만들고 그룹 설정의 '그룹 봇'에 앱을 추가하세요",
+ guideStep2: "위키 '설정' > '멤버 설정' > '멤버 추가'를 열고 해당 그룹 채팅을 검색하여 추가하세요",
+ guideStep3: "그룹 채팅 역할이 최소 '읽기 가능'인지 확인한 후, 돌아와서 다시 시도를 클릭하세요",
+ guideStep1_notion: "Notion에서 동기화하려는 페이지나 데이터베이스를 엽니다",
+ guideStep2_notion: "오른쪽 상단의 '···' 메뉴를 클릭하고 'Connect to' 또는 'Add connections'를 선택합니다",
+ guideStep3_notion: "Integration 앱을 검색하여 선택한 후, 돌아와서 다시 시도를 클릭하세요",
permissionDocLink: "페이슈 위키 권한 설정 문서 보기",
syncScheduleLabel: "동기화 주기",
conflictLabel: "충돌 전략",
diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts
index 9410e14f..94908497 100755
--- a/frontend/src/i18n/locales/ru-RU.ts
+++ b/frontend/src/i18n/locales/ru-RU.ts
@@ -3417,11 +3417,15 @@ export default {
untitled: 'Без названия',
resourceLoadFailed: 'Не удалось загрузить список ресурсов',
noResources: 'Пространства вики не найдены',
- noResourcesHint: 'Сначала сохраните источник данных и проверьте права доступа',
- permissionHint: 'Приложение должно быть добавлено как участник пространства вики для доступа к контенту:',
- permissionStep1: 'Откройте Feishu и перейдите в пространство вики для синхронизации',
- permissionStep2: 'Нажмите "..." > "Настройки" > "Управление участниками"',
- permissionStep3: 'Найдите и добавьте приложение Feishu как участника пространства',
+ noResourcesDesc: 'Приложению требуется доступ к вики через групповой чат для получения контента',
+ noResourcesDesc_notion: 'Приложению требуются права доступа к странице Notion для получения контента',
+ retryLoadResources: 'Повторить',
+ guideStep1: 'Создайте групповой чат в Feishu, затем добавьте ваше приложение как бота в настройках группы',
+ guideStep2: 'Откройте вики "Настройки" > "Управление участниками" > "Добавить участника", найдите групповой чат и добавьте его',
+ guideStep3: 'Убедитесь, что роль группового чата как минимум "Может читать", затем вернитесь и нажмите Повторить',
+ guideStep1_notion: 'Откройте страницу или базу данных, которую хотите синхронизировать в Notion',
+ guideStep2_notion: 'Нажмите меню «···» в правом верхнем углу, выберите «Connect to» или «Add connections»',
+ guideStep3_notion: 'Найдите и выберите ваше интеграционное приложение, затем вернитесь и нажмите Повторить',
permissionDocLink: 'Документация по настройке прав доступа',
syncScheduleLabel: 'Расписание синхронизации',
conflictLabel: 'Стратегия конфликтов',
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 08130813..d6e6b813 100755
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -3457,11 +3457,15 @@ export default {
untitled: "无标题",
resourceLoadFailed: "加载资源列表失败",
noResources: "未找到可同步的知识库空间",
- noResourcesDesc: "应用需要通过群聊获得知识库访问权限才能拉取内容",
+ noResourcesDesc: "应用需要通过群聊获得知识库访问权限才能拉取内容",
+ noResourcesDesc_notion: "应用需要获得 Notion 页面的访问权限才能拉取内容",
retryLoadResources: "重新加载",
guideStep1: "在飞书中创建一个群聊,在群设置「群机器人」中添加你的应用",
guideStep2: "打开知识库「设置」→「成员设置」→ 添加成员,搜索该群聊名称并添加",
guideStep3: "确保群聊角色至少为「可阅读」,然后回到这里点重新加载",
+ guideStep1_notion: "在 Notion 中打开你想要同步的页面或数据库",
+ guideStep2_notion: "点击右上角的「···」菜单,选择「Connect to」或「Add connections」",
+ guideStep3_notion: "搜索并选择你的集成应用(Integration),然后回到这里点重新加载",
permissionDocLink: "查看飞书知识库权限配置文档",
syncScheduleLabel: "同步频率",
conflictLabel: "冲突策略",
diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts
index 3aaed235..010a49e4 100644
--- a/frontend/src/stores/settings.ts
+++ b/frontend/src/stores/settings.ts
@@ -1,5 +1,6 @@
import { defineStore } from "pinia";
import { BUILTIN_QUICK_ANSWER_ID, BUILTIN_SMART_REASONING_ID } from "@/api/agent";
+import { getApiBaseUrl } from "@/utils/api-base";
// 定义设置接口
interface Settings {
@@ -63,7 +64,7 @@ interface OllamaConfig {
// 默认设置
const defaultSettings: Settings = {
- endpoint: import.meta.env.VITE_IS_DOCKER ? "" : "http://localhost:8080",
+ endpoint: getApiBaseUrl(),
apiKey: "",
knowledgeBaseId: "",
isAgentEnabled: false,
diff --git a/frontend/src/utils/api-base.ts b/frontend/src/utils/api-base.ts
new file mode 100644
index 00000000..5e3908c8
--- /dev/null
+++ b/frontend/src/utils/api-base.ts
@@ -0,0 +1,5 @@
+export function getApiBaseUrl(): string {
+ // Use same-origin requests by default.
+ // In local Vite dev, `vite.config.ts` proxies `/api` to the Go backend.
+ return '';
+}
diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts
index e5df898d..b6d77cf8 100644
--- a/frontend/src/utils/request.ts
+++ b/frontend/src/utils/request.ts
@@ -2,11 +2,12 @@
import axios from "axios";
import { generateRandomString } from "./index";
import i18n from '@/i18n'
+import { getApiBaseUrl } from './api-base';
const t = (key: string) => i18n.global.t(key)
// API基础URL
-const BASE_URL = import.meta.env.VITE_IS_DOCKER ? "" : "http://localhost:8080";
+const BASE_URL = getApiBaseUrl();
// 创建Axios实例
diff --git a/frontend/src/views/chat/components/AgentStreamDisplay.vue b/frontend/src/views/chat/components/AgentStreamDisplay.vue
index 8fbbc3c1..2e920524 100644
--- a/frontend/src/views/chat/components/AgentStreamDisplay.vue
+++ b/frontend/src/views/chat/components/AgentStreamDisplay.vue
@@ -1000,9 +1000,15 @@ const handleCitationActivate = (el: HTMLElement) => {
const url = el.getAttribute('data-url');
if (!url) return;
try {
- const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
- if (!newWindow) {
- window.location.assign(url);
+ // @ts-ignore: Wails runtime check
+ if (window.runtime && window.runtime.BrowserOpenURL) {
+ // @ts-ignore
+ window.runtime.BrowserOpenURL(url);
+ } else {
+ const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
+ if (!newWindow) {
+ window.location.assign(url);
+ }
}
} catch {
window.location.assign(url);
@@ -1200,10 +1206,8 @@ const onRootClick = (e: Event) => {
// Handle web citation clicks
const webEl = target.closest?.('.citation-web') as HTMLElement | null;
if (webEl && webEl.getAttribute('data-url')) {
- if (!(webEl instanceof HTMLAnchorElement)) {
- e.preventDefault();
- handleCitationActivate(webEl);
- }
+ e.preventDefault();
+ handleCitationActivate(webEl);
return;
}
@@ -1223,6 +1227,18 @@ const onRootClick = (e: Event) => {
}
return;
}
+
+ // Handle generic a clicks (especially in Wails desktop)
+ const aEl = target.closest?.('a') as HTMLAnchorElement | null;
+ // @ts-ignore
+ if (aEl && aEl.href && window.runtime && window.runtime.BrowserOpenURL) {
+ if (aEl.href.startsWith('http://') || aEl.href.startsWith('https://')) {
+ e.preventDefault();
+ // @ts-ignore
+ window.runtime.BrowserOpenURL(aEl.href);
+ return;
+ }
+ }
};
const onRootKeydown = (e: KeyboardEvent) => {
@@ -1233,15 +1249,8 @@ const onRootKeydown = (e: KeyboardEvent) => {
const webEl = target.closest?.('.citation-web') as HTMLElement | null;
if (webEl) {
if (e.key === 'Enter' || e.key === ' ') {
- if (webEl instanceof HTMLAnchorElement && e.key === 'Enter') {
- return;
- }
e.preventDefault();
- if (webEl instanceof HTMLAnchorElement) {
- webEl.click();
- } else {
- handleCitationActivate(webEl);
- }
+ handleCitationActivate(webEl);
}
return;
}
diff --git a/frontend/src/wailsjs/runtime/package.json b/frontend/src/wailsjs/runtime/package.json
new file mode 100644
index 00000000..1e7c8a5d
--- /dev/null
+++ b/frontend/src/wailsjs/runtime/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wailsapp/runtime",
+ "version": "2.0.0",
+ "description": "Wails Javascript runtime library",
+ "main": "runtime.js",
+ "types": "runtime.d.ts",
+ "scripts": {
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wailsapp/wails.git"
+ },
+ "keywords": [
+ "Wails",
+ "Javascript",
+ "Go"
+ ],
+ "author": "Lea Anthony ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wailsapp/wails/issues"
+ },
+ "homepage": "https://github.com/wailsapp/wails#readme"
+}
diff --git a/frontend/src/wailsjs/runtime/runtime.d.ts b/frontend/src/wailsjs/runtime/runtime.d.ts
new file mode 100644
index 00000000..3bbea848
--- /dev/null
+++ b/frontend/src/wailsjs/runtime/runtime.d.ts
@@ -0,0 +1,330 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+export interface Size {
+ w: number;
+ h: number;
+}
+
+export interface Screen {
+ isCurrent: boolean;
+ isPrimary: boolean;
+ width : number
+ height : number
+}
+
+// Environment information such as platform, buildtype, ...
+export interface EnvironmentInfo {
+ buildType: string;
+ platform: string;
+ arch: string;
+}
+
+// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
+// emits the given event. Optional data may be passed with the event.
+// This will trigger any event listeners.
+export function EventsEmit(eventName: string, ...data: any): void;
+
+// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
+export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
+// sets up a listener for the given event name, but will only trigger a given number times.
+export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
+
+// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
+// sets up a listener for the given event name, but will only trigger once.
+export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
+// unregisters the listener for the given event name.
+export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
+
+// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
+// unregisters all listeners.
+export function EventsOffAll(): void;
+
+// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
+// logs the given message as a raw message
+export function LogPrint(message: string): void;
+
+// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
+// logs the given message at the `trace` log level.
+export function LogTrace(message: string): void;
+
+// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
+// logs the given message at the `debug` log level.
+export function LogDebug(message: string): void;
+
+// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
+// logs the given message at the `error` log level.
+export function LogError(message: string): void;
+
+// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
+// logs the given message at the `fatal` log level.
+// The application will quit after calling this method.
+export function LogFatal(message: string): void;
+
+// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
+// logs the given message at the `info` log level.
+export function LogInfo(message: string): void;
+
+// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
+// logs the given message at the `warning` log level.
+export function LogWarning(message: string): void;
+
+// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
+// Forces a reload by the main application as well as connected browsers.
+export function WindowReload(): void;
+
+// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
+// Reloads the application frontend.
+export function WindowReloadApp(): void;
+
+// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
+// Sets the window AlwaysOnTop or not on top.
+export function WindowSetAlwaysOnTop(b: boolean): void;
+
+// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
+// *Windows only*
+// Sets window theme to system default (dark/light).
+export function WindowSetSystemDefaultTheme(): void;
+
+// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
+// *Windows only*
+// Sets window to light theme.
+export function WindowSetLightTheme(): void;
+
+// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
+// *Windows only*
+// Sets window to dark theme.
+export function WindowSetDarkTheme(): void;
+
+// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
+// Centers the window on the monitor the window is currently on.
+export function WindowCenter(): void;
+
+// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
+// Sets the text in the window title bar.
+export function WindowSetTitle(title: string): void;
+
+// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
+// Makes the window full screen.
+export function WindowFullscreen(): void;
+
+// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
+// Restores the previous window dimensions and position prior to full screen.
+export function WindowUnfullscreen(): void;
+
+// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
+// Returns the state of the window, i.e. whether the window is in full screen mode or not.
+export function WindowIsFullscreen(): Promise;
+
+// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
+// Sets the width and height of the window.
+export function WindowSetSize(width: number, height: number): void;
+
+// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
+// Gets the width and height of the window.
+export function WindowGetSize(): Promise;
+
+// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
+// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMaxSize(width: number, height: number): void;
+
+// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
+// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMinSize(width: number, height: number): void;
+
+// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
+// Sets the window position relative to the monitor the window is currently on.
+export function WindowSetPosition(x: number, y: number): void;
+
+// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
+// Gets the window position relative to the monitor the window is currently on.
+export function WindowGetPosition(): Promise;
+
+// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
+// Hides the window.
+export function WindowHide(): void;
+
+// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
+// Shows the window, if it is currently hidden.
+export function WindowShow(): void;
+
+// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
+// Maximises the window to fill the screen.
+export function WindowMaximise(): void;
+
+// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
+// Toggles between Maximised and UnMaximised.
+export function WindowToggleMaximise(): void;
+
+// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
+// Restores the window to the dimensions and position prior to maximising.
+export function WindowUnmaximise(): void;
+
+// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
+// Returns the state of the window, i.e. whether the window is maximised or not.
+export function WindowIsMaximised(): Promise;
+
+// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
+// Minimises the window.
+export function WindowMinimise(): void;
+
+// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
+// Restores the window to the dimensions and position prior to minimising.
+export function WindowUnminimise(): void;
+
+// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
+// Returns the state of the window, i.e. whether the window is minimised or not.
+export function WindowIsMinimised(): Promise;
+
+// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
+// Returns the state of the window, i.e. whether the window is normal or not.
+export function WindowIsNormal(): Promise;
+
+// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
+// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
+export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
+
+// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
+// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
+export function ScreenGetAll(): Promise;
+
+// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
+// Opens the given URL in the system browser.
+export function BrowserOpenURL(url: string): void;
+
+// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
+// Returns information about the environment
+export function Environment(): Promise;
+
+// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
+// Quits the application.
+export function Quit(): void;
+
+// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
+// Hides the application.
+export function Hide(): void;
+
+// [Show](https://wails.io/docs/reference/runtime/intro#show)
+// Shows the application.
+export function Show(): void;
+
+// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
+// Returns the current text stored on clipboard
+export function ClipboardGetText(): Promise;
+
+// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
+// Sets a text on the clipboard
+export function ClipboardSetText(text: string): Promise;
+
+// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
+// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
+
+// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
+// OnFileDropOff removes the drag and drop listeners and handlers.
+export function OnFileDropOff() :void
+
+// Check if the file path resolver is available
+export function CanResolveFilePaths(): boolean;
+
+// Resolves file paths for an array of files
+export function ResolveFilePaths(files: File[]): void
+
+// Notification types
+export interface NotificationOptions {
+ id: string;
+ title: string;
+ subtitle?: string; // macOS and Linux only
+ body?: string;
+ categoryId?: string;
+ data?: { [key: string]: any };
+}
+
+export interface NotificationAction {
+ id?: string;
+ title?: string;
+ destructive?: boolean; // macOS-specific
+}
+
+export interface NotificationCategory {
+ id?: string;
+ actions?: NotificationAction[];
+ hasReplyField?: boolean;
+ replyPlaceholder?: string;
+ replyButtonTitle?: string;
+}
+
+// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
+// Initializes the notification service for the application.
+// This must be called before sending any notifications.
+export function InitializeNotifications(): Promise;
+
+// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
+// Cleans up notification resources and releases any held connections.
+export function CleanupNotifications(): Promise;
+
+// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
+// Checks if notifications are available on the current platform.
+export function IsNotificationAvailable(): Promise;
+
+// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
+// Requests notification authorization from the user (macOS only).
+export function RequestNotificationAuthorization(): Promise;
+
+// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
+// Checks the current notification authorization status (macOS only).
+export function CheckNotificationAuthorization(): Promise;
+
+// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
+// Sends a basic notification with the given options.
+export function SendNotification(options: NotificationOptions): Promise;
+
+// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
+// Sends a notification with action buttons. Requires a registered category.
+export function SendNotificationWithActions(options: NotificationOptions): Promise;
+
+// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
+// Registers a notification category that can be used with SendNotificationWithActions.
+export function RegisterNotificationCategory(category: NotificationCategory): Promise;
+
+// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
+// Removes a previously registered notification category.
+export function RemoveNotificationCategory(categoryId: string): Promise;
+
+// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
+// Removes all pending notifications from the notification center.
+export function RemoveAllPendingNotifications(): Promise;
+
+// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
+// Removes a specific pending notification by its identifier.
+export function RemovePendingNotification(identifier: string): Promise;
+
+// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
+// Removes all delivered notifications from the notification center.
+export function RemoveAllDeliveredNotifications(): Promise;
+
+// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
+// Removes a specific delivered notification by its identifier.
+export function RemoveDeliveredNotification(identifier: string): Promise;
+
+// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
+// Removes a notification by its identifier (cross-platform convenience function).
+export function RemoveNotification(identifier: string): Promise;
\ No newline at end of file
diff --git a/frontend/src/wailsjs/runtime/runtime.js b/frontend/src/wailsjs/runtime/runtime.js
new file mode 100644
index 00000000..556621ee
--- /dev/null
+++ b/frontend/src/wailsjs/runtime/runtime.js
@@ -0,0 +1,298 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export function LogPrint(message) {
+ window.runtime.LogPrint(message);
+}
+
+export function LogTrace(message) {
+ window.runtime.LogTrace(message);
+}
+
+export function LogDebug(message) {
+ window.runtime.LogDebug(message);
+}
+
+export function LogInfo(message) {
+ window.runtime.LogInfo(message);
+}
+
+export function LogWarning(message) {
+ window.runtime.LogWarning(message);
+}
+
+export function LogError(message) {
+ window.runtime.LogError(message);
+}
+
+export function LogFatal(message) {
+ window.runtime.LogFatal(message);
+}
+
+export function EventsOnMultiple(eventName, callback, maxCallbacks) {
+ return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
+}
+
+export function EventsOn(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, -1);
+}
+
+export function EventsOff(eventName, ...additionalEventNames) {
+ return window.runtime.EventsOff(eventName, ...additionalEventNames);
+}
+
+export function EventsOffAll() {
+ return window.runtime.EventsOffAll();
+}
+
+export function EventsOnce(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, 1);
+}
+
+export function EventsEmit(eventName) {
+ let args = [eventName].slice.call(arguments);
+ return window.runtime.EventsEmit.apply(null, args);
+}
+
+export function WindowReload() {
+ window.runtime.WindowReload();
+}
+
+export function WindowReloadApp() {
+ window.runtime.WindowReloadApp();
+}
+
+export function WindowSetAlwaysOnTop(b) {
+ window.runtime.WindowSetAlwaysOnTop(b);
+}
+
+export function WindowSetSystemDefaultTheme() {
+ window.runtime.WindowSetSystemDefaultTheme();
+}
+
+export function WindowSetLightTheme() {
+ window.runtime.WindowSetLightTheme();
+}
+
+export function WindowSetDarkTheme() {
+ window.runtime.WindowSetDarkTheme();
+}
+
+export function WindowCenter() {
+ window.runtime.WindowCenter();
+}
+
+export function WindowSetTitle(title) {
+ window.runtime.WindowSetTitle(title);
+}
+
+export function WindowFullscreen() {
+ window.runtime.WindowFullscreen();
+}
+
+export function WindowUnfullscreen() {
+ window.runtime.WindowUnfullscreen();
+}
+
+export function WindowIsFullscreen() {
+ return window.runtime.WindowIsFullscreen();
+}
+
+export function WindowGetSize() {
+ return window.runtime.WindowGetSize();
+}
+
+export function WindowSetSize(width, height) {
+ window.runtime.WindowSetSize(width, height);
+}
+
+export function WindowSetMaxSize(width, height) {
+ window.runtime.WindowSetMaxSize(width, height);
+}
+
+export function WindowSetMinSize(width, height) {
+ window.runtime.WindowSetMinSize(width, height);
+}
+
+export function WindowSetPosition(x, y) {
+ window.runtime.WindowSetPosition(x, y);
+}
+
+export function WindowGetPosition() {
+ return window.runtime.WindowGetPosition();
+}
+
+export function WindowHide() {
+ window.runtime.WindowHide();
+}
+
+export function WindowShow() {
+ window.runtime.WindowShow();
+}
+
+export function WindowMaximise() {
+ window.runtime.WindowMaximise();
+}
+
+export function WindowToggleMaximise() {
+ window.runtime.WindowToggleMaximise();
+}
+
+export function WindowUnmaximise() {
+ window.runtime.WindowUnmaximise();
+}
+
+export function WindowIsMaximised() {
+ return window.runtime.WindowIsMaximised();
+}
+
+export function WindowMinimise() {
+ window.runtime.WindowMinimise();
+}
+
+export function WindowUnminimise() {
+ window.runtime.WindowUnminimise();
+}
+
+export function WindowSetBackgroundColour(R, G, B, A) {
+ window.runtime.WindowSetBackgroundColour(R, G, B, A);
+}
+
+export function ScreenGetAll() {
+ return window.runtime.ScreenGetAll();
+}
+
+export function WindowIsMinimised() {
+ return window.runtime.WindowIsMinimised();
+}
+
+export function WindowIsNormal() {
+ return window.runtime.WindowIsNormal();
+}
+
+export function BrowserOpenURL(url) {
+ window.runtime.BrowserOpenURL(url);
+}
+
+export function Environment() {
+ return window.runtime.Environment();
+}
+
+export function Quit() {
+ window.runtime.Quit();
+}
+
+export function Hide() {
+ window.runtime.Hide();
+}
+
+export function Show() {
+ window.runtime.Show();
+}
+
+export function ClipboardGetText() {
+ return window.runtime.ClipboardGetText();
+}
+
+export function ClipboardSetText(text) {
+ return window.runtime.ClipboardSetText(text);
+}
+
+/**
+ * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ *
+ * @export
+ * @callback OnFileDropCallback
+ * @param {number} x - x coordinate of the drop
+ * @param {number} y - y coordinate of the drop
+ * @param {string[]} paths - A list of file paths.
+ */
+
+/**
+ * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+ *
+ * @export
+ * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
+ */
+export function OnFileDrop(callback, useDropTarget) {
+ return window.runtime.OnFileDrop(callback, useDropTarget);
+}
+
+/**
+ * OnFileDropOff removes the drag and drop listeners and handlers.
+ */
+export function OnFileDropOff() {
+ return window.runtime.OnFileDropOff();
+}
+
+export function CanResolveFilePaths() {
+ return window.runtime.CanResolveFilePaths();
+}
+
+export function ResolveFilePaths(files) {
+ return window.runtime.ResolveFilePaths(files);
+}
+
+export function InitializeNotifications() {
+ return window.runtime.InitializeNotifications();
+}
+
+export function CleanupNotifications() {
+ return window.runtime.CleanupNotifications();
+}
+
+export function IsNotificationAvailable() {
+ return window.runtime.IsNotificationAvailable();
+}
+
+export function RequestNotificationAuthorization() {
+ return window.runtime.RequestNotificationAuthorization();
+}
+
+export function CheckNotificationAuthorization() {
+ return window.runtime.CheckNotificationAuthorization();
+}
+
+export function SendNotification(options) {
+ return window.runtime.SendNotification(options);
+}
+
+export function SendNotificationWithActions(options) {
+ return window.runtime.SendNotificationWithActions(options);
+}
+
+export function RegisterNotificationCategory(category) {
+ return window.runtime.RegisterNotificationCategory(category);
+}
+
+export function RemoveNotificationCategory(categoryId) {
+ return window.runtime.RemoveNotificationCategory(categoryId);
+}
+
+export function RemoveAllPendingNotifications() {
+ return window.runtime.RemoveAllPendingNotifications();
+}
+
+export function RemovePendingNotification(identifier) {
+ return window.runtime.RemovePendingNotification(identifier);
+}
+
+export function RemoveAllDeliveredNotifications() {
+ return window.runtime.RemoveAllDeliveredNotifications();
+}
+
+export function RemoveDeliveredNotification(identifier) {
+ return window.runtime.RemoveDeliveredNotification(identifier);
+}
+
+export function RemoveNotification(identifier) {
+ return window.runtime.RemoveNotification(identifier);
+}
\ No newline at end of file
diff --git a/internal/application/repository/retriever/sqlite/repository.go b/internal/application/repository/retriever/sqlite/repository.go
index 88e41126..af4fb9ca 100644
--- a/internal/application/repository/retriever/sqlite/repository.go
+++ b/internal/application/repository/retriever/sqlite/repository.go
@@ -60,13 +60,44 @@ func NewSQLiteRetrieveEngineRepository(db *gorm.DB) interfaces.RetrieveEngineRep
}
func initFTS5(db *gorm.DB) {
- sql := `CREATE VIRTUAL TABLE IF NOT EXISTS lite_embeddings_fts USING fts5(
- content, source_id, chunk_id, knowledge_id, knowledge_base_id,
- content='lite_embeddings', content_rowid='id',
- tokenize='unicode61'
- )`
- if err := db.Exec(sql).Error; err != nil {
- logger.GetLogger(context.Background()).Warnf("[SQLite] Failed to create FTS5 table: %v", err)
+ var sqlStr string
+ db.Raw("SELECT sql FROM sqlite_master WHERE type='table' AND name='lite_embeddings_fts'").Scan(&sqlStr)
+ // If the table exists but is not contentless (i.e. uses content='lite_embeddings'), recreate it
+ if sqlStr != "" && strings.Contains(sqlStr, "content='lite_embeddings'") {
+ logger.GetLogger(context.Background()).Infof("[SQLite] Migrating FTS5 table to contentless table with manual bigram tokenization")
+ db.Exec("DROP TABLE IF EXISTS lite_embeddings_fts")
+ sqlStr = "" // Trigger recreate
+ }
+
+ if sqlStr == "" {
+ createSQL := `CREATE VIRTUAL TABLE IF NOT EXISTS lite_embeddings_fts USING fts5(
+ content, source_id, chunk_id, knowledge_id, knowledge_base_id,
+ content='',
+ contentless_delete=1,
+ tokenize='unicode61'
+ )`
+ if err := db.Exec(createSQL).Error; err != nil {
+ logger.GetLogger(context.Background()).Warnf("[SQLite] Failed to create FTS5 table: %v", err)
+ return
+ }
+ logger.GetLogger(context.Background()).Infof("[SQLite] Populating contentless FTS5 table from lite_embeddings with bigrams")
+
+ // To populate, we need to read all rows and insert them via Go to apply bigram tokenization
+ type Row struct {
+ ID uint
+ Content string
+ SourceID string
+ ChunkID string
+ KnowledgeID string
+ KnowledgeBaseID string
+ }
+ var rows []Row
+ db.Raw("SELECT id, content, source_id, chunk_id, knowledge_id, knowledge_base_id FROM lite_embeddings").Scan(&rows)
+ for _, r := range rows {
+ db.Exec(`INSERT INTO lite_embeddings_fts(rowid, content, source_id, chunk_id, knowledge_id, knowledge_base_id)
+ VALUES(?, ?, ?, ?, ?, ?)`,
+ r.ID, tokenizeCJKBigram(r.Content), r.SourceID, r.ChunkID, r.KnowledgeID, r.KnowledgeBaseID)
+ }
}
}
@@ -276,7 +307,7 @@ func (r *sqliteRepository) keywordsRetrieve(ctx context.Context, params types.Re
SELECT e.id, e.source_id, e.source_type, e.chunk_id,
e.knowledge_id, e.knowledge_base_id, e.tag_id,
e.content,
- bm25(lite_embeddings_fts) AS score
+ (bm25(lite_embeddings_fts) * -1000000.0) AS score
FROM lite_embeddings_fts
JOIN lite_embeddings e ON e.id = lite_embeddings_fts.rowid
WHERE lite_embeddings_fts MATCH ?
@@ -290,7 +321,7 @@ func (r *sqliteRepository) keywordsRetrieve(ctx context.Context, params types.Re
args = append(args, wp.args...)
}
- sql += " ORDER BY score ASC LIMIT ?"
+ sql += " ORDER BY score DESC LIMIT ?"
args = append(args, params.TopK)
type ftsResult struct {
@@ -315,11 +346,12 @@ func (r *sqliteRepository) keywordsRetrieve(ctx context.Context, params types.Re
items := make([]*types.IndexWithScore, len(rows))
for i, row := range rows {
- // bm25 越小越相关 → 转成正向分数
- score := -row.Score
+ // bm25 is originally negative and very small, we multiplied it by -1000000.0 in SQL
+ // to make it positive and human-readable.
+ score := row.Score
- logger.GetLogger(ctx).Infof("[SQLite] keywordsRetrieve: #%d chunk_id=%s, bm25_raw=%.4f, score=%.4f, content_preview=%.60s",
- i+1, row.ChunkID, row.Score, score, row.Content)
+ logger.GetLogger(ctx).Infof("[SQLite] keywordsRetrieve: #%d chunk_id=%s, score=%.4f, content_preview=%.60s",
+ i+1, row.ChunkID, score, row.Content)
items[i] = &types.IndexWithScore{
ID: fmt.Sprintf("%d", row.ID),
@@ -488,6 +520,9 @@ func (r *sqliteRepository) deleteRowsAndVecs(_ context.Context, rows []sqliteEmb
r.db.Exec(fmt.Sprintf("DELETE FROM %s WHERE rowid = ?", tbl), id)
}
}
+ for _, row := range rows {
+ r.db.Exec("DELETE FROM lite_embeddings_fts WHERE rowid = ?", row.ID)
+ }
}
func (r *sqliteRepository) copyVec(_ context.Context, srcID, dstID uint, dim int) {
@@ -505,8 +540,9 @@ func (r *sqliteRepository) syncFTS5Insert(_ context.Context, row *sqliteEmbeddin
if row.ID == 0 {
return
}
+ tokenizedContent := tokenizeCJKBigram(row.Content)
sql := `INSERT INTO lite_embeddings_fts(rowid, content, source_id, chunk_id, knowledge_id, knowledge_base_id) VALUES(?, ?, ?, ?, ?, ?)`
- r.db.Exec(sql, row.ID, row.Content, row.SourceID, row.ChunkID, row.KnowledgeID, row.KnowledgeBaseID)
+ r.db.Exec(sql, row.ID, tokenizedContent, row.SourceID, row.ChunkID, row.KnowledgeID, row.KnowledgeBaseID)
}
type whereClause struct {
@@ -553,61 +589,77 @@ func toInterfaceSlice(ss []string) []interface{} {
return out
}
-// sanitizeFTS5Query builds an FTS5 query from user input.
-// CJK characters are split into overlapping bigrams (mimicking CJK analyzers used
-// by Elasticsearch etc.) and joined with OR for broad partial matching.
-// Non-CJK words are kept intact. BM25 ranking naturally boosts documents
-// that match more tokens.
+// tokenizeCJKBigram splits continuous CJK character sequences into overlapping bigrams.
+// Non-CJK words are kept intact. This approach maximizes recall for CJK search.
+func tokenizeCJKBigram(text string) string {
+ var parts []string
+ var currentCJK []rune
+ var currentNonCJK strings.Builder
+
+ flushCJK := func() {
+ if len(currentCJK) == 0 {
+ return
+ }
+ if len(currentCJK) == 1 {
+ parts = append(parts, string(currentCJK[0]))
+ } else {
+ for i := 0; i < len(currentCJK)-1; i++ {
+ parts = append(parts, string(currentCJK[i])+string(currentCJK[i+1]))
+ }
+ }
+ currentCJK = currentCJK[:0]
+ }
+
+ flushNonCJK := func() {
+ if currentNonCJK.Len() > 0 {
+ parts = append(parts, currentNonCJK.String())
+ currentNonCJK.Reset()
+ }
+ }
+
+ for _, r := range text {
+ // unicode.Han covers Chinese characters
+ if unicode.Is(unicode.Han, r) {
+ flushNonCJK()
+ currentCJK = append(currentCJK, r)
+ } else if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) {
+ // Delimiters
+ flushCJK()
+ flushNonCJK()
+ } else {
+ // Alphanumeric or other languages
+ flushCJK()
+ currentNonCJK.WriteRune(r)
+ }
+ }
+ flushCJK()
+ flushNonCJK()
+
+ return strings.Join(parts, " ")
+}
+
+// sanitizeFTS5Query builds an FTS5 query from user input by applying bigram tokenization.
func sanitizeFTS5Query(q string) string {
q = strings.TrimSpace(q)
if q == "" {
return q
}
- var cjkRunes []rune
- var nonCJKWords []string
- var buf strings.Builder
-
- flushNonCJK := func() {
- if buf.Len() > 0 {
- nonCJKWords = append(nonCJKWords, buf.String())
- buf.Reset()
- }
- }
-
- for _, r := range q {
- if unicode.Is(unicode.Han, r) {
- flushNonCJK()
- cjkRunes = append(cjkRunes, r)
- } else if unicode.IsSpace(r) || r == '|' {
- flushNonCJK()
- } else if r == '"' || r == '*' || r == '(' || r == ')' || r == '{' || r == '}' {
- // skip FTS5 special characters
- } else {
- buf.WriteRune(r)
- }
- }
- flushNonCJK()
+ // Tokenize input query with bigrams (same as during indexing)
+ tokenized := tokenizeCJKBigram(q)
+ fields := strings.Fields(tokenized)
var parts []string
-
- // CJK bigrams for better matching (e.g. "苹果第四季度" → "苹果" OR "果第" OR "第四" OR ...)
- if len(cjkRunes) == 1 {
- parts = append(parts, `"`+string(cjkRunes[0])+`"`)
- } else {
- for i := 0; i < len(cjkRunes)-1; i++ {
- bigram := string(cjkRunes[i]) + string(cjkRunes[i+1])
- parts = append(parts, `"`+bigram+`"`)
+ for _, f := range fields {
+ if f != "" {
+ parts = append(parts, `"`+f+`"`)
}
}
- for _, w := range nonCJKWords {
- w = strings.ReplaceAll(w, `"`, `""`)
- parts = append(parts, `"`+w+`"`)
- }
-
if len(parts) == 0 {
- return `"` + strings.ReplaceAll(q, `"`, `""`) + `"`
+ return ""
}
+
+ // Use OR because we want fuzzy match across multiple bigrams/words
return strings.Join(parts, " OR ")
}
diff --git a/internal/application/service/chat_pipeline/search.go b/internal/application/service/chat_pipeline/search.go
index eb9b24d6..003e08c5 100644
--- a/internal/application/service/chat_pipeline/search.go
+++ b/internal/application/service/chat_pipeline/search.go
@@ -15,15 +15,15 @@ import (
// PluginSearch implements search functionality for chat pipeline
type PluginSearch struct {
- knowledgeBaseService interfaces.KnowledgeBaseService
- knowledgeService interfaces.KnowledgeService
- chunkService interfaces.ChunkService
- config *config.Config
- webSearchService interfaces.WebSearchService
- tenantService interfaces.TenantService
- sessionService interfaces.SessionService
- webSearchStateService interfaces.WebSearchStateService
- webSearchProviderRepo interfaces.WebSearchProviderRepository
+ knowledgeBaseService interfaces.KnowledgeBaseService
+ knowledgeService interfaces.KnowledgeService
+ chunkService interfaces.ChunkService
+ config *config.Config
+ webSearchService interfaces.WebSearchService
+ tenantService interfaces.TenantService
+ sessionService interfaces.SessionService
+ webSearchStateService interfaces.WebSearchStateService
+ webSearchProviderRepo interfaces.WebSearchProviderRepository
}
func NewPluginSearch(eventManager *EventManager,
@@ -620,21 +620,34 @@ func (p *PluginSearch) searchWebIfEnabled(ctx context.Context, chatManage *types
return nil
}
tenant, _ := types.TenantInfoFromContext(ctx)
- if tenant == nil || tenant.WebSearchConfig == nil {
+ providerID := chatManage.WebSearchProviderID
+
+ var webConfig *types.WebSearchConfig
+ if tenant != nil && tenant.WebSearchConfig != nil {
+ // Clone tenant config so we can safely override MaxResults
+ cfg := *tenant.WebSearchConfig
+ webConfig = &cfg
+ } else if providerID != "" {
+ webConfig = &types.WebSearchConfig{
+ MaxResults: 10,
+ }
+ } else {
pipelineWarn(ctx, "Search", "web_config_missing", map[string]interface{}{
"tenant_id": chatManage.TenantID,
})
return nil
}
- // Use provider ID already resolved by session layer (agent config > tenant default)
- providerID := chatManage.WebSearchProviderID
+ // Apply agent-level web search overrides
+ if chatManage.WebSearchMaxResults > 0 {
+ webConfig.MaxResults = chatManage.WebSearchMaxResults
+ }
pipelineInfo(ctx, "Search", "web_request", map[string]interface{}{
"tenant_id": chatManage.TenantID,
"provider_id": providerID,
})
- webResults, err := p.webSearchService.Search(ctx, providerID, tenant.WebSearchConfig, chatManage.RewriteQuery)
+ webResults, err := p.webSearchService.Search(ctx, providerID, webConfig, chatManage.RewriteQuery)
if err != nil {
pipelineWarn(ctx, "Search", "web_search_error", map[string]interface{}{
"tenant_id": chatManage.TenantID,
@@ -643,22 +656,22 @@ func (p *PluginSearch) searchWebIfEnabled(ctx context.Context, chatManage *types
return nil
}
// Build questions using RewriteQuery only
- questions := []string{strings.TrimSpace(chatManage.RewriteQuery)}
+ // questions := []string{strings.TrimSpace(chatManage.RewriteQuery)}
// Load session-scoped temp KB state from Redis using WebSearchStateRepository
- tempKBID, seen, ids := p.webSearchStateService.GetWebSearchTempKBState(ctx, chatManage.SessionID)
- compressed, kbID, newSeen, newIDs, err := p.webSearchService.CompressWithRAG(
- ctx, chatManage.SessionID, tempKBID, questions, webResults, tenant.WebSearchConfig,
- p.knowledgeBaseService, p.knowledgeService, seen, ids,
- )
- if err != nil {
- pipelineWarn(ctx, "Search", "web_compress_error", map[string]interface{}{
- "error": err.Error(),
- })
- } else {
- webResults = compressed
- // Persist temp KB state back into Redis using WebSearchStateRepository
- p.webSearchStateService.SaveWebSearchTempKBState(ctx, chatManage.SessionID, kbID, newSeen, newIDs)
- }
+ // tempKBID, seen, ids := p.webSearchStateService.GetWebSearchTempKBState(ctx, chatManage.SessionID)
+ // compressed, kbID, newSeen, newIDs, err := p.webSearchService.CompressWithRAG(
+ // ctx, chatManage.SessionID, tempKBID, questions, webResults, webConfig,
+ // p.knowledgeBaseService, p.knowledgeService, seen, ids,
+ // )
+ // if err != nil {
+ // pipelineWarn(ctx, "Search", "web_compress_error", map[string]interface{}{
+ // "error": err.Error(),
+ // })
+ // } else {
+ // webResults = compressed
+ // // Persist temp KB state back into Redis using WebSearchStateRepository
+ // p.webSearchStateService.SaveWebSearchTempKBState(ctx, chatManage.SessionID, kbID, newSeen, newIDs)
+ // }
res := searchutil.ConvertWebSearchResults(webResults)
pipelineInfo(ctx, "Search", "web_hits", map[string]interface{}{
"hit_count": len(res),
diff --git a/internal/application/service/session_knowledge_qa.go b/internal/application/service/session_knowledge_qa.go
index 49ca2e7a..145b21ee 100644
--- a/internal/application/service/session_knowledge_qa.go
+++ b/internal/application/service/session_knowledge_qa.go
@@ -115,6 +115,7 @@ func (s *sessionService) KnowledgeQA(
RewritePromptUser: s.cfg.Conversation.RewritePromptUser,
WebSearchEnabled: req.WebSearchEnabled,
WebSearchProviderID: s.resolveWebSearchProviderID(ctx, req, retrievalTenantID),
+ WebSearchMaxResults: s.resolveWebSearchMaxResults(ctx, req),
WebFetchEnabled: s.resolveWebFetchEnabled(req),
WebFetchTopN: s.resolveWebFetchTopN(req),
TenantID: retrievalTenantID,
@@ -841,3 +842,16 @@ func (s *sessionService) resolveWebFetchTopN(req *types.QARequest) int {
}
return 3
}
+
+// resolveWebSearchMaxResults returns the max results for web search.
+// Priority: agent config > tenant default > default (10)
+func (s *sessionService) resolveWebSearchMaxResults(ctx context.Context, req *types.QARequest) int {
+ if req.CustomAgent != nil && req.CustomAgent.Config.WebSearchMaxResults > 0 {
+ return req.CustomAgent.Config.WebSearchMaxResults
+ }
+ tenantInfo, _ := types.TenantInfoFromContext(ctx)
+ if tenantInfo != nil && tenantInfo.WebSearchConfig != nil && tenantInfo.WebSearchConfig.MaxResults > 0 {
+ return tenantInfo.WebSearchConfig.MaxResults
+ }
+ return 10
+}
diff --git a/internal/application/service/session_qa_helpers.go b/internal/application/service/session_qa_helpers.go
index b3fc2d5a..b7680b1c 100644
--- a/internal/application/service/session_qa_helpers.go
+++ b/internal/application/service/session_qa_helpers.go
@@ -176,6 +176,11 @@ func (s *sessionService) applyAgentOverridesToChatManage(
cm.FallbackPrompt = customAgent.Config.FallbackPrompt
}
+ // Override web search settings
+ if customAgent.Config.WebSearchMaxResults > 0 {
+ cm.WebSearchMaxResults = customAgent.Config.WebSearchMaxResults
+ }
+
// Override history turns
if customAgent.Config.HistoryTurns > 0 {
cm.MaxRounds = customAgent.Config.HistoryTurns
diff --git a/internal/application/service/web_search.go b/internal/application/service/web_search.go
index 4adb03fa..e41cc2bc 100644
--- a/internal/application/service/web_search.go
+++ b/internal/application/service/web_search.go
@@ -7,8 +7,8 @@ import (
"strings"
"time"
- infra_web_search "github.com/Tencent/WeKnora/internal/infrastructure/web_search"
"github.com/Tencent/WeKnora/internal/config"
+ infra_web_search "github.com/Tencent/WeKnora/internal/infrastructure/web_search"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/searchutil"
"github.com/Tencent/WeKnora/internal/types"
@@ -216,8 +216,8 @@ func (s *WebSearchService) CompressWithRAG(
for _, q := range questions {
params := types.SearchParams{
QueryText: q,
- VectorThreshold: 0.5,
- KeywordThreshold: 0.5,
+ VectorThreshold: 0.2,
+ KeywordThreshold: 0.2,
MatchCount: matchCount,
}
results, err := kbSvc.HybridSearch(ctx, tempKBID, params)
diff --git a/internal/application/service/web_search_state.go b/internal/application/service/web_search_state.go
index 430e133c..811458cb 100644
--- a/internal/application/service/web_search_state.go
+++ b/internal/application/service/web_search_state.go
@@ -36,6 +36,9 @@ func (s *webSearchStateService) GetWebSearchTempKBState(
ctx context.Context,
sessionID string,
) (tempKBID string, seenURLs map[string]bool, knowledgeIDs []string) {
+ if s.redisClient == nil {
+ return "", make(map[string]bool), []string{}
+ }
stateKey := fmt.Sprintf("tempkb:%s", sessionID)
if raw, getErr := s.redisClient.Get(ctx, stateKey).Bytes(); getErr == nil && len(raw) > 0 {
var state struct {
@@ -65,6 +68,9 @@ func (s *webSearchStateService) SaveWebSearchTempKBState(
seenURLs map[string]bool,
knowledgeIDs []string,
) {
+ if s.redisClient == nil {
+ return
+ }
stateKey := fmt.Sprintf("tempkb:%s", sessionID)
state := struct {
KBID string `json:"kbID"`
diff --git a/internal/container/container.go b/internal/container/container.go
index 30a019e9..18e478b7 100644
--- a/internal/container/container.go
+++ b/internal/container/container.go
@@ -355,6 +355,7 @@ func initContextStorage(redisClient *redis.Client) (llmcontext.ContextStorage, e
func initDatabase(cfg *config.Config) (*gorm.DB, error) {
var dialector gorm.Dialector
var migrateDSN string
+ var sqliteDBPath string
switch os.Getenv("DB_DRIVER") {
case "postgres":
// DSN for GORM (key-value format)
@@ -412,6 +413,7 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) {
sqlite_vec.Auto()
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on"
dialector = sqlite.Open(dsn)
+ sqliteDBPath = dbPath
migrateDSN = "sqlite3://" + dbPath
logger.Infof(context.Background(), "DB Config: driver=sqlite path=%s", dbPath)
default:
@@ -445,6 +447,7 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) {
autoRecover := os.Getenv("AUTO_RECOVER_DIRTY") != "false"
migrationOpts := database.MigrationOptions{
AutoRecoverDirty: autoRecover,
+ SQLiteDBPath: sqliteDBPath,
}
// Run base migrations (all versioned migrations including embeddings)
@@ -505,6 +508,56 @@ func resolveStorageProviderPending(db *gorm.DB) {
} else if result.RowsAffected > 0 {
logger.Infof(context.Background(), "Resolved %d knowledge bases with __pending_env__ storage provider → %s", result.RowsAffected, storageType)
}
+
+ // Reset any pending tasks left over from previous aborted runs (Lite App mode)
+ resetPendingTasks(db)
+}
+
+// resetPendingTasks resets the state of any knowledge items or sync logs stuck in processing
+// due to an unexpected application restart when using in-memory queues (Lite mode).
+func resetPendingTasks(db *gorm.DB) {
+ if os.Getenv("REDIS_ADDR") != "" {
+ return // Distributed queue (Asynq) will handle its own retries
+ }
+
+ // 1. Reset knowledge parsing tasks
+ result := db.Model(&types.Knowledge{}).
+ Where("parse_status IN ?", []string{types.ParseStatusPending, types.ParseStatusProcessing, types.ParseStatusDeleting}).
+ Updates(map[string]interface{}{
+ "parse_status": types.ParseStatusFailed,
+ "error_message": "Task interrupted due to application restart",
+ })
+ if result.Error != nil {
+ logger.Warnf(context.Background(), "Failed to reset pending knowledge tasks: %v", result.Error)
+ } else if result.RowsAffected > 0 {
+ logger.Infof(context.Background(), "Reset %d stuck knowledge parsing tasks to failed state", result.RowsAffected)
+ }
+
+ // 2. Reset knowledge summary tasks
+ resultSummary := db.Model(&types.Knowledge{}).
+ Where("summary_status IN ?", []string{types.SummaryStatusPending, types.SummaryStatusProcessing}).
+ Updates(map[string]interface{}{
+ "summary_status": types.SummaryStatusFailed,
+ })
+ if resultSummary.Error != nil {
+ logger.Warnf(context.Background(), "Failed to reset pending summary tasks: %v", resultSummary.Error)
+ } else if resultSummary.RowsAffected > 0 {
+ logger.Infof(context.Background(), "Reset %d stuck summary generation tasks to failed state", resultSummary.RowsAffected)
+ }
+
+ // 3. Reset data source sync tasks
+ resultSync := db.Model(&types.SyncLog{}).
+ Where("status IN ?", []string{types.SyncLogStatusRunning, "pending"}).
+ Updates(map[string]interface{}{
+ "status": types.SyncLogStatusFailed,
+ "error_message": "Sync interrupted due to application restart",
+ "end_time": time.Now(),
+ })
+ if resultSync.Error != nil {
+ logger.Warnf(context.Background(), "Failed to reset pending data source sync tasks: %v", resultSync.Error)
+ } else if resultSync.RowsAffected > 0 {
+ logger.Infof(context.Background(), "Reset %d stuck data source sync tasks to failed state", resultSync.RowsAffected)
+ }
}
// initFileService initializes file storage service
diff --git a/internal/database/migration.go b/internal/database/migration.go
index 5c836b2e..a57108ea 100644
--- a/internal/database/migration.go
+++ b/internal/database/migration.go
@@ -2,6 +2,7 @@ package database
import (
"context"
+ "database/sql"
"fmt"
"os"
"strings"
@@ -10,7 +11,7 @@ import (
"github.com/Tencent/WeKnora/internal/logger"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
- _ "github.com/golang-migrate/migrate/v4/database/sqlite3"
+ sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
@@ -46,6 +47,12 @@ type MigrationOptions struct {
// AutoRecoverDirty when true, automatically attempts to recover from dirty state
// by forcing to the previous version and retrying the migration
AutoRecoverDirty bool
+
+ // SQLiteDBPath is the raw filesystem path to the SQLite database file.
+ // When set, the migrator opens the DB directly via sql.Open instead of
+ // parsing a URL-based DSN, which avoids breakage when the path contains
+ // spaces (e.g. macOS "Application Support").
+ SQLiteDBPath string
}
// RunMigrationsWithOptions executes all pending database migrations with custom options
@@ -59,10 +66,31 @@ func RunMigrationsWithOptions(dsn string, opts MigrationOptions) error {
migrationsPath = "file://migrations/sqlite"
}
- m, err := migrate.New(migrationsPath, dsn)
- if err != nil {
- logger.Errorf(ctx, "Failed to create migrate instance: %v", err)
- return fmt.Errorf("failed to create migrate instance: %w", err)
+ var m *migrate.Migrate
+ if opts.SQLiteDBPath != "" {
+ sqlDB, err := sql.Open("sqlite3", opts.SQLiteDBPath)
+ if err != nil {
+ logger.Errorf(ctx, "Failed to open sqlite db for migration: %v", err)
+ return fmt.Errorf("failed to open sqlite db for migration: %w", err)
+ }
+ driver, err := sqlite3migrate.WithInstance(sqlDB, &sqlite3migrate.Config{})
+ if err != nil {
+ sqlDB.Close()
+ logger.Errorf(ctx, "Failed to create sqlite3 migrate driver: %v", err)
+ return fmt.Errorf("failed to create sqlite3 migrate driver: %w", err)
+ }
+ m, err = migrate.NewWithDatabaseInstance(migrationsPath, "sqlite3", driver)
+ if err != nil {
+ logger.Errorf(ctx, "Failed to create migrate instance: %v", err)
+ return fmt.Errorf("failed to create migrate instance: %w", err)
+ }
+ } else {
+ var err error
+ m, err = migrate.New(migrationsPath, dsn)
+ if err != nil {
+ logger.Errorf(ctx, "Failed to create migrate instance: %v", err)
+ return fmt.Errorf("failed to create migrate instance: %w", err)
+ }
}
defer m.Close()
diff --git a/internal/datasource/connector/notion/connector.go b/internal/datasource/connector/notion/connector.go
index 42a8ab8d..487b3a8e 100644
--- a/internal/datasource/connector/notion/connector.go
+++ b/internal/datasource/connector/notion/connector.go
@@ -394,9 +394,6 @@ func (c *Connector) fetchDatabase(ctx context.Context, client *notionClient, id
if err != nil || len(records) == 0 {
return nil
}
- // Same database may be reached via two different IDs (data_source_id from search
- // vs database_container_id from child_database blocks). Mark the canonical
- // data_source_id as visited so the second path is deduped.
if queryID != "" && queryID != id {
if visited[queryID] {
return nil
@@ -404,18 +401,15 @@ func (c *Connector) fetchDatabase(ctx context.Context, client *notionClient, id
visited[queryID] = true
}
- propNames := extractPropertySchema(records[0])
- var items []types.FetchedItem
for _, record := range records {
- if record.InTrash {
- continue
- }
- item := c.buildRecordItem(ctx, client, record, propNames, dbTitle)
- if item != nil {
- items = append(items, *item)
- }
+ visited[record.ID] = true // Mark records as visited to avoid duplicate fetchPage calls
}
- return items
+
+ item := c.buildDatabaseItem(ctx, client, id, dbTitle, records)
+ if item != nil {
+ return []types.FetchedItem{*item}
+ }
+ return nil
}
// fetchDatabaseIncremental syncs only changed records by comparing edit times against cursor.
@@ -438,32 +432,33 @@ func (c *Connector) fetchDatabaseIncremental(ctx context.Context, client *notion
}
recordEditTimes := make(map[string]time.Time, len(records))
- var propNames []string
- if len(records) > 0 {
- propNames = extractPropertySchema(records[0])
- }
-
- var items []types.FetchedItem
changedCount := 0
+
for _, record := range records {
+ visited[record.ID] = true // Mark records as visited to avoid duplicate fetchPage calls
+
if record.InTrash {
continue
}
recordEditTimes[record.ID] = record.LastEditedTime
prevTime, existed := prevEditTimes[record.ID]
- if existed && record.LastEditedTime.Equal(prevTime) {
- continue
- }
- changedCount++
- item := c.buildRecordItem(ctx, client, record, propNames, dbTitle)
- if item != nil {
- items = append(items, *item)
+ if !existed || !record.LastEditedTime.Equal(prevTime) {
+ changedCount++
}
}
logger.Infof(ctx, "[Notion] database %s incremental: %d changed out of %d records", id, changedCount, len(records))
- return items, recordEditTimes
+
+ // Rebuild the entire database table if any record changed, or if we don't have previous times (first sync)
+ if changedCount > 0 || len(prevEditTimes) == 0 {
+ item := c.buildDatabaseItem(ctx, client, id, dbTitle, records)
+ if item != nil {
+ return []types.FetchedItem{*item}, recordEditTimes
+ }
+ }
+
+ return nil, recordEditTimes
}
// queryDatabaseRecords resolves the database ID and queries all records.
@@ -545,6 +540,103 @@ func (c *Connector) buildRecordItem(ctx context.Context, client *notionClient, r
}
}
+// buildDatabaseItem converts a database into a single Markdown table document.
+func (c *Connector) buildDatabaseItem(ctx context.Context, client *notionClient, id string, dbTitle string, records []notionPage) *types.FetchedItem {
+ if len(records) == 0 {
+ return nil
+ }
+
+ propNames := extractPropertySchema(records[0])
+
+ var content strings.Builder
+ title := dbTitle
+ if title == "" {
+ title = defaultUntitledName
+ }
+ content.WriteString("# " + title + "\n\n")
+
+ // Table header
+ content.WriteString("| Title ")
+ for _, name := range propNames {
+ content.WriteString("| " + strings.ReplaceAll(name, "|", "\\|") + " ")
+ }
+ content.WriteString("|\n|")
+ content.WriteString("---|")
+ for range propNames {
+ content.WriteString("---|")
+ }
+ content.WriteString("\n")
+
+ var extraContent strings.Builder
+
+ // Table rows
+ for _, record := range records {
+ if record.InTrash {
+ continue
+ }
+
+ recordTitle := record.Title
+ if recordTitle == "" {
+ recordTitle = defaultUntitledName
+ }
+
+ content.WriteString("| " + strings.ReplaceAll(recordTitle, "|", "\\|") + " ")
+
+ if record.RawProperties != nil {
+ var props map[string]interface{}
+ json.Unmarshal(record.RawProperties, &props)
+ for _, name := range propNames {
+ val := ""
+ if propMap, ok := props[name].(map[string]interface{}); ok {
+ val = propertyToString(propMap)
+ }
+ val = strings.ReplaceAll(val, "\n", "
")
+ val = strings.ReplaceAll(val, "|", "\\|")
+ content.WriteString("| " + val + " ")
+ }
+ }
+ content.WriteString("|\n")
+
+ // Fetch blocks for the record, in case it has page content
+ blocks, err := client.GetBlockChildrenAll(ctx, record.ID)
+ if err == nil && len(blocks) > 0 {
+ resolveFileUploads(ctx, client, blocks)
+ markdown, _ := BlocksToMarkdown(blocks)
+ if strings.TrimSpace(markdown) != "" {
+ extraContent.WriteString("\n## " + recordTitle + " 内容\n\n" + markdown + "\n")
+ }
+ }
+ }
+
+ if extraContent.Len() > 0 {
+ content.WriteString("\n" + extraContent.String())
+ }
+
+ bodyStr := strings.TrimSpace(content.String())
+ if bodyStr == "" {
+ return nil
+ }
+
+ updatedAt := time.Now()
+ if len(records) > 0 {
+ updatedAt = records[0].LastEditedTime
+ }
+
+ return &types.FetchedItem{
+ ExternalID: id,
+ Title: title,
+ Content: []byte(bodyStr),
+ ContentType: contentTypeMarkdown,
+ FileName: title + ".md",
+ URL: fmt.Sprintf("https://notion.so/%s", strings.ReplaceAll(id, "-", "")),
+ UpdatedAt: updatedAt,
+ Metadata: map[string]string{
+ "channel": types.ChannelNotion,
+ "object_type": objectTypeDatabase,
+ },
+ }
+}
+
// discoverAllResources uses the Search API to find all pages and data_sources,
// then filters by parent chain via BFS from the selected resource IDs.
// Returns the included pages plus the excluded set (visible pages NOT under any
diff --git a/internal/datasource/connector/notion/connector_test.go b/internal/datasource/connector/notion/connector_test.go
index 1c21f410..5846e085 100644
--- a/internal/datasource/connector/notion/connector_test.go
+++ b/internal/datasource/connector/notion/connector_test.go
@@ -2,6 +2,7 @@ package notion
import (
"context"
+ "strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
@@ -121,16 +122,13 @@ func TestConnectorFetchAll_Database(t *testing.T) {
t.Fatal("expected at least 1 item")
}
- // Each database record should be synced as an individual knowledge item
+ // The entire database should be synced as a single table knowledge item
found := false
for _, item := range items {
- if item.ExternalID == "record-1" {
+ if item.ExternalID == "db-1" {
found = true
- if item.Metadata["object_type"] != "page" {
- t.Errorf("object_type = %q, want %q", item.Metadata["object_type"], "page")
- }
- if item.Metadata["database"] != "Test Database" {
- t.Errorf("database = %q, want %q", item.Metadata["database"], "Test Database")
+ if item.Metadata["object_type"] != "database" {
+ t.Errorf("object_type = %q, want %q", item.Metadata["object_type"], "database")
}
if item.ContentType != "text/markdown" {
t.Errorf("ContentType = %q", item.ContentType)
@@ -138,10 +136,15 @@ func TestConnectorFetchAll_Database(t *testing.T) {
if len(item.Content) == 0 {
t.Error("expected non-empty content")
}
+ // Verify it contains table headers
+ contentStr := string(item.Content)
+ if !strings.Contains(contentStr, "| Title |") {
+ t.Errorf("expected table header in content, got: %s", contentStr)
+ }
}
}
if !found {
- t.Error("record-1 not found in items")
+ t.Error("db-1 not found in items")
}
}
diff --git a/internal/datasource/connector/notion/markdown_test.go b/internal/datasource/connector/notion/markdown_test.go
index ad6b4c0c..9a256e35 100644
--- a/internal/datasource/connector/notion/markdown_test.go
+++ b/internal/datasource/connector/notion/markdown_test.go
@@ -134,7 +134,7 @@ func TestBlocksToMarkdown_Paragraph(t *testing.T) {
}),
}
md, attachments := BlocksToMarkdown(blocks)
- if md != "Hello world\n\n" {
+ if md != "Hello world\n" {
t.Errorf("markdown = %q", md)
}
if len(attachments) != 0 {
@@ -161,7 +161,7 @@ func TestBlocksToMarkdown_Headings(t *testing.T) {
}),
}
md, _ := BlocksToMarkdown(blocks)
- expected := "# H1\n\n## H2\n\n### H3\n\n"
+ expected := "# H1\n\n## H2\n\n### H3\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
@@ -177,7 +177,7 @@ func TestBlocksToMarkdown_Code(t *testing.T) {
}),
}
md, _ := BlocksToMarkdown(blocks)
- expected := "```go\nfmt.Println()\n```\n\n"
+ expected := "```go\nfmt.Println()\n```\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
@@ -197,7 +197,7 @@ func TestBlocksToMarkdown_List(t *testing.T) {
}),
}
md, _ := BlocksToMarkdown(blocks)
- expected := "- Item 1\n- Item 2\n\n"
+ expected := "- Item 1\n- Item 2\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
@@ -219,7 +219,7 @@ func TestBlocksToMarkdown_ToDo(t *testing.T) {
}),
}
md, _ := BlocksToMarkdown(blocks)
- expected := "- [ ] Unchecked\n- [x] Checked\n\n"
+ expected := "- [ ] Unchecked\n- [x] Checked\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
@@ -234,7 +234,7 @@ func TestBlocksToMarkdown_Quote(t *testing.T) {
}),
}
md, _ := BlocksToMarkdown(blocks)
- expected := "> A wise quote\n\n"
+ expected := "> A wise quote\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
@@ -245,7 +245,7 @@ func TestBlocksToMarkdown_Divider(t *testing.T) {
{ID: "blk-div", Type: "divider"},
}
md, _ := BlocksToMarkdown(blocks)
- if md != "---\n\n" {
+ if md != "---\n" {
t.Errorf("markdown = %q", md)
}
}
@@ -257,7 +257,7 @@ func TestBlocksToMarkdown_Equation(t *testing.T) {
}),
}
md, _ := BlocksToMarkdown(blocks)
- expected := "$$E = mc^2$$\n\n"
+ expected := "$$E = mc^2$$\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
@@ -277,7 +277,7 @@ func TestBlocksToMarkdown_Image(t *testing.T) {
}),
}
md, attachments := BlocksToMarkdown(blocks)
- if md != "\n\n" {
+ if md != "\n" {
t.Errorf("markdown = %q", md)
}
if len(attachments) != 1 {
@@ -296,7 +296,7 @@ func TestBlocksToMarkdown_UnknownType(t *testing.T) {
{ID: "blk-unknown", Type: "future_block_type"},
}
md, _ := BlocksToMarkdown(blocks)
- if md != "" {
+ if md != "\n" {
t.Errorf("unknown block should produce empty output, got %q", md)
}
}
@@ -323,7 +323,7 @@ func TestBlocksToMarkdown_Table(t *testing.T) {
},
}
md, _ := BlocksToMarkdown([]notionBlock{tableBlock})
- expected := "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n\n"
+ expected := "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
@@ -345,7 +345,7 @@ func TestBlocksToMarkdown_NestedList(t *testing.T) {
}
md, _ := BlocksToMarkdown([]notionBlock{parent})
- expected := "- Parent\n - Child\n\n"
+ expected := "- Parent\n - Child\n"
if md != expected {
t.Errorf("markdown = %q, want %q", md, expected)
}
diff --git a/internal/im/wecom/longconn.go b/internal/im/wecom/longconn.go
index 20b236e1..e9ead313 100644
--- a/internal/im/wecom/longconn.go
+++ b/internal/im/wecom/longconn.go
@@ -286,6 +286,7 @@ func (c *LongConnClient) SendStreamChunk(ctx context.Context, incoming *im.Incom
}
// EndStream sends the final frame with the full accumulated content and cleans up.
+// It retries briefly if the connection is temporarily unavailable during a reconnect.
func (c *LongConnClient) EndStream(ctx context.Context, incoming *im.IncomingMessage, streamID string) error {
c.streamBufsMu.Lock()
buf, ok := c.streamBufs[streamID]
@@ -296,7 +297,23 @@ func (c *LongConnClient) EndStream(ctx context.Context, incoming *im.IncomingMes
}
c.streamBufsMu.Unlock()
- return c.sendStreamFrame(incoming, streamID, fullContent, true)
+ err := c.sendStreamFrame(incoming, streamID, fullContent, true)
+ if err == nil {
+ return nil
+ }
+
+ // Retry up to 3 times with short delays to ride out a reconnection window.
+ for i := 0; i < 3; i++ {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(500 * time.Millisecond):
+ }
+ if retryErr := c.sendStreamFrame(incoming, streamID, fullContent, true); retryErr == nil {
+ return nil
+ }
+ }
+ return err
}
func (c *LongConnClient) sendStreamFrame(incoming *im.IncomingMessage, streamID, content string, finish bool) error {
@@ -342,12 +359,11 @@ func (c *LongConnClient) connectAndRun(ctx context.Context) error {
c.conn = nil
c.mu.Unlock()
_ = conn.Close()
-
- // Clear in-flight stream buffers to prevent memory leaks on reconnect.
- // Streams interrupted by a connection drop cannot be resumed.
- c.streamBufsMu.Lock()
- c.streamBufs = nil
- c.streamBufsMu.Unlock()
+ // NOTE: streamBufs is intentionally NOT cleared on reconnect.
+ // Active streams survive reconnections — the WeCom replace-based
+ // protocol means the next SendStreamChunk will resend the full
+ // accumulated content on the new connection. EndStream always
+ // cleans up the buffer, so there is no memory leak.
}()
// Authenticate
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index 3eb08ee4..a1ad7f4f 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -3,11 +3,14 @@ package logger
import (
"context"
"fmt"
+ "io"
"os"
"path"
+ "path/filepath"
"runtime"
"sort"
"strings"
+ "sync"
"github.com/Tencent/WeKnora/internal/types"
"github.com/sirupsen/logrus"
@@ -16,6 +19,11 @@ import (
// appLogger 使用私有实例,避免外部依赖改写 logrus 全局状态导致日志丢失
var appLogger = logrus.New()
+var (
+ loggerMu sync.Mutex
+ activeLogFile *os.File
+)
+
// LogLevel 日志级别类型
type LogLevel string
@@ -130,12 +138,38 @@ func (f *CustomFormatter) Format(entry *logrus.Entry) ([]byte, error) {
// 初始化全局日志设置
func init() {
+ ConfigureFromEnv()
+}
+
+// ConfigureFromEnv 重新从环境变量应用日志配置。
+// 这允许在 main() 中加载 .env 后,让 LOG_LEVEL / LOG_PATH 立即生效。
+func ConfigureFromEnv() {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
+
+ if activeLogFile != nil {
+ _ = activeLogFile.Close()
+ activeLogFile = nil
+ }
+
// 根据环境变量设置全局日志级别
logLevel := getLogLevelFromEnv()
appLogger.SetLevel(logLevel)
- // 统一输出到 stdout,确保在 Docker 容器中与 GORM/GIN 日志合并展示
- appLogger.SetOutput(os.Stdout)
+ writer := io.Writer(os.Stdout)
+ logPath := resolveLogPathFromEnv()
+ if logPath != "" {
+ file, err := openLogFile(logPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "logger: failed to open log file %s: %v\n", logPath, err)
+ } else {
+ activeLogFile = file
+ writer = io.MultiWriter(os.Stdout, file)
+ }
+ }
+
+ // 默认继续输出到 stdout,同时在可用时落盘到文件
+ appLogger.SetOutput(writer)
// 非终端(如 Docker 日志采集)禁用 ANSI 颜色,避免日志聚合/检索异常
forceColor := false
@@ -199,6 +233,45 @@ func getLogLevelFromEnv() logrus.Level {
}
}
+func resolveLogPathFromEnv() string {
+ if logPath := strings.TrimSpace(os.Getenv("LOG_PATH")); logPath != "" {
+ return filepath.Clean(logPath)
+ }
+ return defaultMacAppLogPath()
+}
+
+func defaultMacAppLogPath() string {
+ execPath, err := os.Executable()
+ if err != nil || !strings.Contains(execPath, ".app/Contents/MacOS") {
+ return ""
+ }
+
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+
+ appName := "WeKnora Lite"
+ if idx := strings.Index(execPath, ".app/Contents/MacOS"); idx >= 0 {
+ bundleName := filepath.Base(execPath[:idx+4])
+ if trimmed := strings.TrimSuffix(bundleName, ".app"); trimmed != "" {
+ appName = trimmed
+ }
+ }
+
+ return filepath.Join(homeDir, "Library", "Logs", appName, appName+".log")
+}
+
+func openLogFile(logPath string) (*os.File, error) {
+ dir := filepath.Dir(logPath)
+ if dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+ }
+ return os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+}
+
// 添加调用者字段
func addCaller(entry *logrus.Entry, skip int) *logrus.Entry {
pc, file, line, ok := runtime.Caller(skip)
diff --git a/internal/types/chat_manage.go b/internal/types/chat_manage.go
index 10dca6cf..1e8a0265 100644
--- a/internal/types/chat_manage.go
+++ b/internal/types/chat_manage.go
@@ -46,12 +46,13 @@ type PipelineRequest struct {
ChatModelSupportsVision bool `json:"-"`
// Misc request-scoped config
- TenantID uint64 `json:"-"`
- WebSearchEnabled bool `json:"-"`
- WebSearchProviderID string `json:"-"` // Resolved from agent config or tenant default
- WebFetchEnabled bool `json:"-"` // Auto-fetch full page content for web search results after rerank
- WebFetchTopN int `json:"-"` // Max pages to fetch (default 3)
- Language string `json:"-"`
+ TenantID uint64 `json:"-"`
+ WebSearchEnabled bool `json:"-"`
+ WebSearchProviderID string `json:"-"` // Resolved from agent config or tenant default
+ WebSearchMaxResults int `json:"-"` // Resolved from agent config or tenant default
+ WebFetchEnabled bool `json:"-"` // Auto-fetch full page content for web search results after rerank
+ WebFetchTopN int `json:"-"` // Max pages to fetch (default 3)
+ Language string `json:"-"`
}
// QueryIntent represents the classified intent of a user query.
@@ -187,9 +188,10 @@ func (c *ChatManage) Clone() *ChatManage {
ChatModelSupportsVision: c.ChatModelSupportsVision,
TenantID: c.TenantID,
WebSearchEnabled: c.WebSearchEnabled,
- WebSearchProviderID: c.WebSearchProviderID,
- WebFetchEnabled: c.WebFetchEnabled,
- WebFetchTopN: c.WebFetchTopN,
+ WebSearchProviderID: c.WebSearchProviderID,
+ WebSearchMaxResults: c.WebSearchMaxResults,
+ WebFetchEnabled: c.WebFetchEnabled,
+ WebFetchTopN: c.WebFetchTopN,
Language: c.Language,
},
PipelineState: PipelineState{
diff --git a/internal/types/json.go b/internal/types/json.go
index e2aae2db..98756520 100644
--- a/internal/types/json.go
+++ b/internal/types/json.go
@@ -12,9 +12,24 @@ type JSON json.RawMessage
// Scan implements the sql.Scanner interface.
func (j *JSON) Scan(value interface{}) error {
- bytes, ok := value.([]byte)
- if !ok {
- return errors.New("type assertion to []byte failed")
+ if value == nil {
+ *j = nil
+ return nil
+ }
+
+ var bytes []byte
+ switch v := value.(type) {
+ case []byte:
+ bytes = v
+ case string:
+ bytes = []byte(v)
+ default:
+ return errors.New("type assertion to []byte or string failed")
+ }
+
+ if len(bytes) == 0 {
+ *j = nil
+ return nil
}
result := json.RawMessage{}