fix: update environment configuration for debugging and logging, enhance SQLite FTS5 table management, and improve API base URL handling

This commit is contained in:
wizardchen
2026-04-11 05:48:53 +08:00
committed by lyingbug
parent 8a4d58726e
commit 9a2b54536d
35 changed files with 1486 additions and 341 deletions
+10 -1
View File
@@ -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
+1
View File
@@ -29,6 +29,7 @@ logs/
WeKnora
WeKnora-lite
cmd/desktop/build/bin/
/models/
test/data/mswag.txt
data/files/
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>
+160 -146
View File
@@ -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<layoutClasses.length;i++){
if(el.classList.contains(layoutClasses[i]))return true;
}
var tag=el.tagName;
return tag==='BODY'||tag==='HTML';
}
function shouldDrag(el,y){
if(!(el instanceof Element))return false;
if(el.closest(noDragSel))return false;
if(el.closest(dragSel))return true;
if(y<=TITLEBAR_H&&isLayoutEl(el))return true;
return false;
}
window.addEventListener('mousedown',function(e){
var target=e.target;
if(target&&target.nodeType===Node.TEXT_NODE){
target=target.parentElement;
}
if(e.button!==0||e.detail!==1)return;
if(!shouldDrag(target,e.clientY))return;
e.preventDefault();
sendDrag();
},true);
})();`
func main() {
// For macOS .app bundle, the working directory is usually "/" or the MacOS folder.
// We need to change the working directory to the Resources folder where our configs are.
@@ -44,6 +111,8 @@ func main() {
// Load .env explicitly for the desktop app so DB_DRIVER gets loaded
_ = godotenv.Load()
configureDesktopStorage(execPath)
logger.ConfigureFromEnv()
// Set Gin mode
if os.Getenv("GIN_MODE") == "release" {
@@ -143,14 +212,14 @@ func main() {
os.Exit(0)
})
EditMenu := AppMenu.AddSubmenu("Edit")
EditMenu.AddText("Undo", keys.CmdOrCtrl("z"), func(_ *menu.CallbackData) {})
EditMenu.AddText("Redo", keys.CmdOrCtrl("y"), func(_ *menu.CallbackData) {})
EditMenu.AddSeparator()
EditMenu.AddText("Cut", keys.CmdOrCtrl("x"), func(_ *menu.CallbackData) {})
EditMenu.AddText("Copy", keys.CmdOrCtrl("c"), func(_ *menu.CallbackData) {})
EditMenu.AddText("Paste", keys.CmdOrCtrl("v"), func(_ *menu.CallbackData) {})
EditMenu.AddText("Select All", keys.CmdOrCtrl("a"), func(_ *menu.CallbackData) {})
AppMenu.Append(menu.EditMenu())
ViewMenu := AppMenu.AddSubmenu("View")
ViewMenu.AddText("Reload", keys.CmdOrCtrl("r"), func(_ *menu.CallbackData) {
if app.ctx != nil {
wailsruntime.WindowReloadApp(app.ctx)
}
})
// Wait for the backend URL to be set
targetURL, _ := url.Parse(app.backendURL)
@@ -159,150 +228,18 @@ func main() {
// Start Wails application
// We use a Reverse Proxy to seamlessly proxy Wails' frontend to our Go backend
err := wails.Run(&options.App{
Title: "WeKnora Lite",
Width: 1280,
Height: 800,
DisableResize: true,
Menu: AppMenu,
Title: "WeKnora Lite",
Width: 1280,
Height: 800,
DisableResize: false,
Menu: AppMenu,
AssetServer: &assetserver.Options{
Handler: proxy,
},
StartHidden: false, // Show window on startup
OnStartup: app.startup,
OnDomReady: func(ctx context.Context) {
// Inject CSS and event delegation to make top areas draggable for macOS hiddenInset titlebar.
// Wails checks the clicked element itself, so for dynamic SPA content we also bind a document-level
// mousedown handler and directly trigger native dragging for matching header regions.
css := `
const style = document.createElement('style');
style.innerHTML = ` + "`" + `
.logo_row, .logo_row *,
.menu_top, .menu_top *,
.chat-header, .chat-header *,
.header, .header *,
.dialog-header, .dialog-header *,
.sidebar-header, .sidebar-header *,
.document-header, .document-header *,
.section-header, .section-header *,
.header-title, .header-title * {
--wails-draggable: drag !important;
}
.header-actions, .header-actions *,
.header-action-btn, .header-action-btn *,
.sidebar-toggle, .sidebar-toggle *,
.logo_box, .logo_box *,
.close-btn, .close-btn *,
button, button *,
a, a *,
input, select, textarea,
[role="button"], [role="button"] *,
.t-button, .t-button *,
.t-input, .t-select, .t-textarea,
svg[data-no-drag], svg[data-no-drag] * {
--wails-draggable: no-drag !important;
}
` + "`" + `;
document.head.appendChild(style);
(function() {
if (window.__weknoraDragBound) return;
window.__weknoraDragBound = true;
const dragSelectors = [
'.logo_row',
'.menu_top',
'.chat-header',
'.header',
'.header-title',
'.section-header',
'.dialog-header',
'.sidebar-header',
'.document-header'
];
const noDragSelectors = [
'.header-actions',
'.header-action-btn',
'.sidebar-toggle',
'.logo_box',
'.close-btn',
'button',
'a',
'input',
'select',
'textarea',
'[role="button"]',
'.t-button',
'.t-input',
'.t-select',
'.t-textarea',
'[data-no-drag]'
];
const isNoDrag = (el) => {
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 {
+2 -2
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
a8c281c741c2499a02564295ccfb3f91
+2 -1
View File
@@ -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');
+1 -1
View File
@@ -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}`;
}
+5 -1
View File
@@ -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',
+9 -5
View File
@@ -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: "충돌 전략",
+9 -5
View File
@@ -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: 'Стратегия конфликтов',
+5 -1
View File
@@ -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: "冲突策略",
+2 -1
View File
@@ -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,
+5
View File
@@ -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 '';
}
+2 -1
View File
@@ -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实例
@@ -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;
}
+24
View File
@@ -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 <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+330
View File
@@ -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<boolean>;
// [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<Size>;
// [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<Position>;
// [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<boolean>;
// [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<boolean>;
// [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<boolean>;
// [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<Screen[]>;
// [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<EnvironmentInfo>;
// [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<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [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<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [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<void>;
// [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<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [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<void>;
+298
View File
@@ -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);
}
@@ -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 ")
}
@@ -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),
@@ -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
}
@@ -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
+3 -3
View File
@@ -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)
@@ -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"`
+53
View File
@@ -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
+33 -5
View File
@@ -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()
+119 -27
View File
@@ -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", "<br>")
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
@@ -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")
}
}
@@ -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 != "![A photo](https://s3.example.com/img.png)\n\n" {
if md != "![A photo](https://s3.example.com/img.png)\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)
}
+23 -7
View File
@@ -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
+75 -2
View File
@@ -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)
+11 -9
View File
@@ -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{
+18 -3
View File
@@ -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{}