fix: update environment configuration for debugging and logging, enhance SQLite FTS5 table management, and improve API base URL handling
This commit is contained in:
+10
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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 {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
a8c281c741c2499a02564295ccfb3f91
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "충돌 전략",
|
||||
|
||||
@@ -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: 'Стратегия конфликтов',
|
||||
|
||||
@@ -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: "冲突策略",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 != "\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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user