Repository: FreeU-group/LifeTrace Branch: main Commit: 800cd27344fe Files: 1003 Total size: 14.1 MB Directory structure: gitextract_0o688pqc/ ├── .cursor/ │ ├── commands/ │ │ ├── agno_agent.md │ │ ├── agno_agent_CN.md │ │ ├── backend.md │ │ ├── backend_CN.md │ │ ├── dynamic-island.md │ │ ├── web.md │ │ └── web_CN.md │ └── plans/ │ ├── lifetrace_全面优化_76b5f86f.plan.md │ ├── tauri_迁移方案_38d8ea4b.plan.md │ ├── 后台持续录音方案_c7c8f0fe.plan.md │ └── 打包与性能优化_ecd1657a.plan.md ├── .gitattributes ├── .githooks/ │ ├── post-checkout │ └── pre-commit ├── .github/ │ ├── BACKEND_GUIDELINES.md │ ├── BACKEND_GUIDELINES_CN.md │ ├── CONTRIBUTING.md │ ├── CONTRIBUTING_CN.md │ ├── FRONTEND_GUIDELINES.md │ ├── FRONTEND_GUIDELINES_CN.md │ ├── GIT_FLOW.md │ ├── GIT_FLOW_CN.md │ ├── INSTALL.md │ ├── INSTALL_CN.md │ ├── PRE_COMMIT_GUIDE.md │ ├── PRE_COMMIT_GUIDE_CN.md │ ├── ROADMAP.md │ ├── ROADMAP_CN.md │ ├── dependabot.yml │ └── workflows/ │ ├── _disabled/ │ │ ├── dev-build-verify.yml │ │ └── tauri-release.yml │ ├── dependency-review.yml │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── AGENTS.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── README_CN.md ├── bandit.yaml ├── biome.json ├── free-todo-frontend/ │ ├── .gitignore │ ├── app/ │ │ ├── globals.css │ │ ├── home/ │ │ │ ├── HomePageClient.tsx │ │ │ └── HomePageEntry.tsx │ │ ├── island/ │ │ │ ├── island.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── apps/ │ │ ├── achievements/ │ │ │ └── AchievementsPanel.tsx │ │ ├── activity/ │ │ │ ├── ActivityCard.tsx │ │ │ ├── ActivityDetail.tsx │ │ │ ├── ActivityHeader.tsx │ │ │ ├── ActivityPanel.tsx │ │ │ ├── ActivitySidebar.tsx │ │ │ ├── ActivitySummary.tsx │ │ │ └── utils/ │ │ │ └── timeUtils.ts │ │ ├── audio/ │ │ │ ├── AudioPanel.tsx │ │ │ ├── components/ │ │ │ │ ├── AudioExtractionPanel.tsx │ │ │ │ ├── AudioHeader.tsx │ │ │ │ ├── AudioList.tsx │ │ │ │ ├── AudioPlayer.tsx │ │ │ │ ├── RecordingStatus.tsx │ │ │ │ ├── StopRecordingConfirm.tsx │ │ │ │ └── TranscriptionView.tsx │ │ │ ├── hooks/ │ │ │ │ ├── useAudioData.ts │ │ │ │ ├── useAudioDateSwitching.ts │ │ │ │ ├── useAudioLink.ts │ │ │ │ ├── useAudioPlayback.ts │ │ │ │ ├── useAudioRecording.ts │ │ │ │ ├── useAudioRecordingControl.ts │ │ │ │ ├── useSegmentSync.ts │ │ │ │ └── useStopRecordingConfirm.ts │ │ │ └── utils/ │ │ │ ├── parseTimeToIsoWithDate.ts │ │ │ └── timeUtils.ts │ │ ├── calendar/ │ │ │ ├── CalendarPanel.tsx │ │ │ ├── components/ │ │ │ │ ├── DayColumn.tsx │ │ │ │ ├── DraggableTodo.tsx │ │ │ │ ├── FloatingTodoCard.tsx │ │ │ │ ├── QuickCreateBar.tsx │ │ │ │ ├── QuickCreatePopover.tsx │ │ │ │ ├── TimelineColumn.tsx │ │ │ │ ├── TimelineCreatePopover.tsx │ │ │ │ ├── TimelineSlot.tsx │ │ │ │ └── TimelineTodoCard.tsx │ │ │ ├── hooks/ │ │ │ │ └── useMonthScroll.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── views/ │ │ │ ├── DayView.tsx │ │ │ ├── MonthScroller.tsx │ │ │ ├── MonthView.tsx │ │ │ ├── WeekView.tsx │ │ │ └── useWeekViewActions.ts │ │ ├── chat/ │ │ │ ├── ChatPanel.tsx │ │ │ ├── components/ │ │ │ │ ├── breakdown/ │ │ │ │ │ ├── BreakdownStageRenderer.tsx │ │ │ │ │ ├── BreakdownSummary.tsx │ │ │ │ │ └── Questionnaire.tsx │ │ │ │ ├── input/ │ │ │ │ │ ├── ChatInputSection.tsx │ │ │ │ │ ├── InputBox.tsx │ │ │ │ │ ├── LinkedTodos.tsx │ │ │ │ │ ├── PromptSuggestions.tsx │ │ │ │ │ └── ToolSelector.tsx │ │ │ │ ├── layout/ │ │ │ │ │ ├── HeaderBar.tsx │ │ │ │ │ ├── HistoryDrawer.tsx │ │ │ │ │ └── WelcomeGreetings.tsx │ │ │ │ └── message/ │ │ │ │ ├── EditModeMessage.tsx │ │ │ │ ├── MarkdownComponents.tsx │ │ │ │ ├── MessageContent.tsx │ │ │ │ ├── MessageContextMenu.tsx │ │ │ │ ├── MessageItem.tsx │ │ │ │ ├── MessageList.tsx │ │ │ │ ├── MessageSources.tsx │ │ │ │ ├── MessageTodoExtractionModal.tsx │ │ │ │ ├── MessageTodoExtractionPanel.tsx │ │ │ │ ├── SummaryStreaming.tsx │ │ │ │ ├── ToolCallLoading.tsx │ │ │ │ ├── ToolCallSteps.tsx │ │ │ │ └── utils/ │ │ │ │ └── messageContentUtils.ts │ │ │ ├── hooks/ │ │ │ │ ├── useBreakdownQuestionnaire.ts │ │ │ │ ├── useBreakdownService.ts │ │ │ │ ├── useChatController.ts │ │ │ │ ├── useChatPrompts.ts │ │ │ │ ├── useMessageExtraction.ts │ │ │ │ ├── useMessageScroll.ts │ │ │ │ ├── usePlanParser.ts │ │ │ │ ├── useSendMessage.ts │ │ │ │ ├── useSessionCache.ts │ │ │ │ ├── useSessionManager.ts │ │ │ │ ├── useStreamController.ts │ │ │ │ └── useToolCallTracker.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── id.ts │ │ │ ├── messageBuilder.ts │ │ │ ├── parseEditBlocks.ts │ │ │ ├── responseHandlers.ts │ │ │ └── todoContext.ts │ │ ├── cost-tracking/ │ │ │ ├── CostTrackingPanel.tsx │ │ │ └── index.ts │ │ ├── debug/ │ │ │ ├── DebugCapturePanel.tsx │ │ │ ├── components/ │ │ │ │ ├── EventCard.tsx │ │ │ │ ├── EventSearchForm.tsx │ │ │ │ ├── ScreenshotModal.tsx │ │ │ │ ├── SelectedEventsBar.tsx │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── useEventActions.ts │ │ │ │ └── useEventData.ts │ │ │ └── utils.ts │ │ ├── diary/ │ │ │ ├── DiaryEditor.tsx │ │ │ ├── DiaryHeader.tsx │ │ │ ├── DiaryPanel.tsx │ │ │ ├── DiarySettings.tsx │ │ │ ├── DiaryTabs.tsx │ │ │ ├── JournalHistory.tsx │ │ │ ├── index.ts │ │ │ ├── journal-utils.ts │ │ │ └── types.ts │ │ ├── settings/ │ │ │ ├── SettingsPanel.tsx │ │ │ ├── components/ │ │ │ │ ├── AudioAsrConfigSection.tsx │ │ │ │ ├── AudioConfigSection.tsx │ │ │ │ ├── AutoTodoDetectionSection.tsx │ │ │ │ ├── AutomationTasksSection.tsx │ │ │ │ ├── DifyConfigSection.tsx │ │ │ │ ├── DockDisplayModeSection.tsx │ │ │ │ ├── JournalSettingsSection.tsx │ │ │ │ ├── LlmConfigSection.tsx │ │ │ │ ├── NotificationPermissionSection.tsx │ │ │ │ ├── OnboardingSection.tsx │ │ │ │ ├── PanelSwitchesSection.tsx │ │ │ │ ├── RecorderConfigSection.tsx │ │ │ │ ├── SchedulerSection.tsx │ │ │ │ ├── SettingsCategoryPanel.tsx │ │ │ │ ├── SettingsSearchAction.tsx │ │ │ │ ├── SettingsSection.tsx │ │ │ │ ├── TavilyConfigSection.tsx │ │ │ │ ├── ToggleSwitch.tsx │ │ │ │ ├── VersionInfoSection.tsx │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ └── useSettingsSearchMatchStats.ts │ │ │ └── index.ts │ │ ├── todo-detail/ │ │ │ ├── TodoDetail.tsx │ │ │ ├── components/ │ │ │ │ ├── ArtifactsView.tsx │ │ │ │ ├── AttachmentPreviewPanel.tsx │ │ │ │ ├── BackgroundSection.tsx │ │ │ │ ├── ChildTodoSection.tsx │ │ │ │ ├── DatePickerCalendar.tsx │ │ │ │ ├── DatePickerPopover.tsx │ │ │ │ ├── DatePickerSidePanel.tsx │ │ │ │ ├── DetailHeader.tsx │ │ │ │ ├── DetailTitle.tsx │ │ │ │ ├── MetaSection.tsx │ │ │ │ ├── NotesEditor.tsx │ │ │ │ └── datePickerUtils.ts │ │ │ ├── helpers.ts │ │ │ ├── hooks/ │ │ │ │ └── useNotesAutosize.ts │ │ │ ├── index.ts │ │ │ └── utils/ │ │ │ ├── date-utils.ts │ │ │ ├── holiday-utils.ts │ │ │ ├── index.ts │ │ │ └── lunar-utils.ts │ │ └── todo-list/ │ │ ├── CreateTodoForm.tsx │ │ ├── NewTodoInlineForm.tsx │ │ ├── TodoCard.tsx │ │ ├── TodoExtractionModal.tsx │ │ ├── TodoList.tsx │ │ ├── TodoToolbar.tsx │ │ ├── TodoTreeList.tsx │ │ ├── components/ │ │ │ ├── TodoCardCheckbox.tsx │ │ │ ├── TodoCardChildForm.tsx │ │ │ ├── TodoCardDropZone.tsx │ │ │ ├── TodoCardExpandButton.tsx │ │ │ ├── TodoCardMetadata.tsx │ │ │ ├── TodoCardName.tsx │ │ │ └── TodoFilter.tsx │ │ ├── hooks/ │ │ │ ├── useOrderedTodos.ts │ │ │ ├── useTodoCardDrag.ts │ │ │ ├── useTodoCardHandlers.ts │ │ │ └── useTodoCardState.ts │ │ ├── index.ts │ │ └── utils/ │ │ └── todoCardUtils.ts │ ├── components/ │ │ ├── common/ │ │ │ ├── ReminderOptions.tsx │ │ │ ├── context-menu/ │ │ │ │ ├── BaseContextMenu.tsx │ │ │ │ ├── MultiTodoContextMenu.tsx │ │ │ │ └── TodoContextMenu.tsx │ │ │ ├── layout/ │ │ │ │ ├── CollapsibleSection.tsx │ │ │ │ ├── LayoutSelector.tsx │ │ │ │ ├── LayoutSelectorDialogs.tsx │ │ │ │ ├── PanelHeader.tsx │ │ │ │ └── SectionHeader.tsx │ │ │ ├── theme/ │ │ │ │ ├── ThemeProvider.tsx │ │ │ │ ├── ThemeStyleSelect.tsx │ │ │ │ └── ThemeToggle.tsx │ │ │ └── ui/ │ │ │ ├── BackendReadyGate.tsx │ │ │ ├── CapabilitiesSync.tsx │ │ │ ├── DockTriggerZone.tsx │ │ │ ├── FrontendBoot.tsx │ │ │ ├── LanguageToggle.tsx │ │ │ ├── LocaleSync.tsx │ │ │ ├── ScrollbarController.tsx │ │ │ ├── SettingsToggle.tsx │ │ │ └── UserAvatar.tsx │ │ ├── date-picker/ │ │ │ ├── DateOnlyPickerCalendar.tsx │ │ │ ├── DateOnlyPickerPopover.tsx │ │ │ └── date-picker-utils.ts │ │ ├── island/ │ │ │ ├── DynamicIsland.tsx │ │ │ ├── IslandContent.tsx │ │ │ ├── IslandFullscreenContent.tsx │ │ │ ├── IslandHeader.tsx │ │ │ ├── IslandSidebarContent.tsx │ │ │ └── index.ts │ │ ├── layout/ │ │ │ ├── AppHeader.tsx │ │ │ ├── BottomDock.tsx │ │ │ ├── FullscreenHeader.tsx │ │ │ ├── PanelContainer.tsx │ │ │ ├── PanelContent.tsx │ │ │ ├── PanelRegion.tsx │ │ │ ├── PanelSelectorMenu.tsx │ │ │ └── ResizeHandle.tsx │ │ ├── notification/ │ │ │ └── HeaderIsland.tsx │ │ └── ui/ │ │ ├── alert-dialog.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ └── dropdown-menu.tsx │ ├── electron/ │ │ ├── PACKAGING_GUIDE.md │ │ ├── PACKAGING_GUIDE_CN.md │ │ ├── backend-server.ts │ │ ├── bootstrap-control.ts │ │ ├── bootstrap-status.ts │ │ ├── bootstrap-window.ts │ │ ├── config.ts │ │ ├── git-info.ts │ │ ├── global-shortcut-manager.ts │ │ ├── ipc-handlers-todo-capture.ts │ │ ├── ipc-handlers.ts │ │ ├── island-window-manager.ts │ │ ├── logger.ts │ │ ├── main.ts │ │ ├── next-server.ts │ │ ├── notification.ts │ │ ├── port-manager.ts │ │ ├── preload.ts │ │ ├── process-manager.ts │ │ ├── python-runtime-command.ts │ │ ├── python-runtime-env.ts │ │ ├── python-runtime-installer.ts │ │ ├── python-runtime.ts │ │ ├── runtime-paths.ts │ │ ├── tray-manager.ts │ │ ├── tsconfig.json │ │ └── window-manager.ts │ ├── electron-builder.island.pyinstaller.yml │ ├── electron-builder.island.script.yml │ ├── electron-builder.island.yml │ ├── electron-builder.web.pyinstaller.yml │ ├── electron-builder.web.script.yml │ ├── electron-builder.web.yml │ ├── electron-builder.yml │ ├── global.d.ts │ ├── lib/ │ │ ├── api/ │ │ │ └── fetcher.ts │ │ ├── api.ts │ │ ├── attachments.ts │ │ ├── config/ │ │ │ └── panel-config.ts │ │ ├── dnd/ │ │ │ ├── context.tsx │ │ │ ├── handlers.ts │ │ │ ├── index.ts │ │ │ ├── overlays.tsx │ │ │ └── types.ts │ │ ├── generated/ │ │ │ ├── activity/ │ │ │ │ └── activity.ts │ │ │ ├── audio/ │ │ │ │ └── audio.ts │ │ │ ├── case-transform.ts │ │ │ ├── chat/ │ │ │ │ └── chat.ts │ │ │ ├── config/ │ │ │ │ └── config.ts │ │ │ ├── cost-tracking/ │ │ │ │ └── cost-tracking.ts │ │ │ ├── default/ │ │ │ │ └── default.ts │ │ │ ├── event/ │ │ │ │ └── event.ts │ │ │ ├── floating-capture/ │ │ │ │ └── floating-capture.ts │ │ │ ├── journals/ │ │ │ │ └── journals.ts │ │ │ ├── logs/ │ │ │ │ └── logs.ts │ │ │ ├── notifications/ │ │ │ │ └── notifications.ts │ │ │ ├── ocr/ │ │ │ │ └── ocr.ts │ │ │ ├── proactive-ocr/ │ │ │ │ └── proactive-ocr.ts │ │ │ ├── rag/ │ │ │ │ └── rag.ts │ │ │ ├── scheduler/ │ │ │ │ └── scheduler.ts │ │ │ ├── schemas/ │ │ │ │ ├── activityEventsResponse.ts │ │ │ │ ├── activityListResponse.ts │ │ │ │ ├── activityResponse.ts │ │ │ │ ├── activityResponseAiSummary.ts │ │ │ │ ├── activityResponseAiTitle.ts │ │ │ │ ├── activityResponseCreatedAt.ts │ │ │ │ ├── activityResponseUpdatedAt.ts │ │ │ │ ├── addMessageRequest.ts │ │ │ │ ├── audioLinkItem.ts │ │ │ │ ├── audioLinkRequest.ts │ │ │ │ ├── bodyImportIcsApiTodosImportIcsPost.ts │ │ │ │ ├── bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost.ts │ │ │ │ ├── capabilitiesResponse.ts │ │ │ │ ├── capabilitiesResponseMissingDeps.ts │ │ │ │ ├── chatMessage.ts │ │ │ │ ├── chatMessageContext.ts │ │ │ │ ├── chatMessageConversationId.ts │ │ │ │ ├── chatMessageExternalTools.ts │ │ │ │ ├── chatMessageMode.ts │ │ │ │ ├── chatMessageProjectId.ts │ │ │ │ ├── chatMessageSelectedTools.ts │ │ │ │ ├── chatMessageSystemPrompt.ts │ │ │ │ ├── chatMessageTaskIds.ts │ │ │ │ ├── chatMessageUserInput.ts │ │ │ │ ├── chatMessageWithContext.ts │ │ │ │ ├── chatMessageWithContextConversationId.ts │ │ │ │ ├── chatMessageWithContextEventContext.ts │ │ │ │ ├── chatMessageWithContextEventContextAnyOfItem.ts │ │ │ │ ├── chatMessageWorkspacePath.ts │ │ │ │ ├── chatResponse.ts │ │ │ │ ├── chatResponsePerformance.ts │ │ │ │ ├── chatResponsePerformanceAnyOf.ts │ │ │ │ ├── chatResponseQueryInfo.ts │ │ │ │ ├── chatResponseQueryInfoAnyOf.ts │ │ │ │ ├── chatResponseRetrievalInfo.ts │ │ │ │ ├── chatResponseRetrievalInfoAnyOf.ts │ │ │ │ ├── chatResponseSessionId.ts │ │ │ │ ├── cleanupOldDataApiCleanupPostParams.ts │ │ │ │ ├── contextListResponse.ts │ │ │ │ ├── contextResponse.ts │ │ │ │ ├── contextResponseAiSummary.ts │ │ │ │ ├── contextResponseAiTitle.ts │ │ │ │ ├── contextResponseAppName.ts │ │ │ │ ├── contextResponseCreatedAt.ts │ │ │ │ ├── contextResponseEndTime.ts │ │ │ │ ├── contextResponseProjectId.ts │ │ │ │ ├── contextResponseStartTime.ts │ │ │ │ ├── contextResponseTaskId.ts │ │ │ │ ├── contextResponseWindowTitle.ts │ │ │ │ ├── contextUpdateRequest.ts │ │ │ │ ├── contextUpdateRequestProjectId.ts │ │ │ │ ├── contextUpdateRequestTaskId.ts │ │ │ │ ├── countEventsApiEventsCountGetParams.ts │ │ │ │ ├── createdTodo.ts │ │ │ │ ├── createdTodoScheduledTime.ts │ │ │ │ ├── eventDetailResponse.ts │ │ │ │ ├── eventDetailResponseAiSummary.ts │ │ │ │ ├── eventDetailResponseAiTitle.ts │ │ │ │ ├── eventDetailResponseAppName.ts │ │ │ │ ├── eventDetailResponseEndTime.ts │ │ │ │ ├── eventDetailResponseWindowTitle.ts │ │ │ │ ├── eventListResponse.ts │ │ │ │ ├── eventResponse.ts │ │ │ │ ├── eventResponseAiSummary.ts │ │ │ │ ├── eventResponseAiTitle.ts │ │ │ │ ├── eventResponseAppName.ts │ │ │ │ ├── eventResponseEndTime.ts │ │ │ │ ├── eventResponseFirstScreenshotId.ts │ │ │ │ ├── eventResponseWindowTitle.ts │ │ │ │ ├── exportIcsApiTodosExportIcsGetParams.ts │ │ │ │ ├── extractTodosAndSchedulesApiAudioExtractPostParams.ts │ │ │ │ ├── extractedMessageTodo.ts │ │ │ │ ├── extractedMessageTodoDescription.ts │ │ │ │ ├── extractedTodo.ts │ │ │ │ ├── extractedTodoConfidence.ts │ │ │ │ ├── extractedTodoDescription.ts │ │ │ │ ├── extractedTodoScheduledTime.ts │ │ │ │ ├── extractedTodoSourceText.ts │ │ │ │ ├── extractedTodoTimeInfo.ts │ │ │ │ ├── extractedTodoTimeInfoAnyOf.ts │ │ │ │ ├── floatingCaptureRequest.ts │ │ │ │ ├── floatingCaptureResponse.ts │ │ │ │ ├── generateTasksResponse.ts │ │ │ │ ├── generatedTaskItem.ts │ │ │ │ ├── generatedTaskItemDescription.ts │ │ │ │ ├── getChatHistoryApiChatHistoryGetParams.ts │ │ │ │ ├── getChatPromptsApiGetChatPromptsGetParams.ts │ │ │ │ ├── getContextsApiContextsGetParams.ts │ │ │ │ ├── getCostStatsApiCostTrackingStatsGetParams.ts │ │ │ │ ├── getLogContentApiLogsContentGetParams.ts │ │ │ │ ├── getProjectTasksApiProjectsProjectIdTasksGetParams.ts │ │ │ │ ├── getProjectsApiProjectsGetParams.ts │ │ │ │ ├── getQuerySuggestionsApiChatSuggestionsGetParams.ts │ │ │ │ ├── getRecordingsApiAudioRecordingsGetParams.ts │ │ │ │ ├── getScreenshotsApiScreenshotsGetParams.ts │ │ │ │ ├── getTaskProgressApiProjectsProjectIdTasksTaskIdProgressGetParams.ts │ │ │ │ ├── getTaskProgressLatestApiProjectsProjectIdTasksTaskIdProgressLatestGet200.ts │ │ │ │ ├── getTimeAllocationApiTimeAllocationGetParams.ts │ │ │ │ ├── getTimelineApiAudioTimelineGetParams.ts │ │ │ │ ├── getTranscriptionApiAudioTranscriptionRecordingIdGetParams.ts │ │ │ │ ├── hTTPValidationError.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jobInfo.ts │ │ │ │ ├── jobInfoName.ts │ │ │ │ ├── jobInfoNextRunTime.ts │ │ │ │ ├── jobIntervalUpdateRequest.ts │ │ │ │ ├── jobIntervalUpdateRequestHours.ts │ │ │ │ ├── jobIntervalUpdateRequestMinutes.ts │ │ │ │ ├── jobIntervalUpdateRequestSeconds.ts │ │ │ │ ├── jobListResponse.ts │ │ │ │ ├── jobOperationResponse.ts │ │ │ │ ├── journalAutoLinkCandidate.ts │ │ │ │ ├── journalAutoLinkRequest.ts │ │ │ │ ├── journalAutoLinkResponse.ts │ │ │ │ ├── journalCreate.ts │ │ │ │ ├── journalGenerateRequest.ts │ │ │ │ ├── journalGenerateResponse.ts │ │ │ │ ├── journalListResponse.ts │ │ │ │ ├── journalResponse.ts │ │ │ │ ├── journalResponseDeletedAt.ts │ │ │ │ ├── journalTag.ts │ │ │ │ ├── journalUpdate.ts │ │ │ │ ├── journalUpdateContentFormat.ts │ │ │ │ ├── journalUpdateDate.ts │ │ │ │ ├── journalUpdateName.ts │ │ │ │ ├── journalUpdateTagIds.ts │ │ │ │ ├── journalUpdateUserNotes.ts │ │ │ │ ├── lifetraceSchemasFloatingCaptureExtractedTodo.ts │ │ │ │ ├── lifetraceSchemasFloatingCaptureExtractedTodoDescription.ts │ │ │ │ ├── lifetraceSchemasFloatingCaptureExtractedTodoSourceText.ts │ │ │ │ ├── lifetraceSchemasFloatingCaptureExtractedTodoTimeInfo.ts │ │ │ │ ├── lifetraceSchemasFloatingCaptureExtractedTodoTimeInfoAnyOf.ts │ │ │ │ ├── lifetraceSchemasTodoExtractionExtractedTodo.ts │ │ │ │ ├── lifetraceSchemasTodoExtractionExtractedTodoConfidence.ts │ │ │ │ ├── lifetraceSchemasTodoExtractionExtractedTodoDescription.ts │ │ │ │ ├── lifetraceSchemasTodoExtractionExtractedTodoScheduledTime.ts │ │ │ │ ├── linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams.ts │ │ │ │ ├── listActivitiesApiActivitiesGetParams.ts │ │ │ │ ├── listEventsApiEventsGetParams.ts │ │ │ │ ├── listJournalsApiJournalsGetParams.ts │ │ │ │ ├── listTodosApiTodosGetParams.ts │ │ │ │ ├── manualActivityCreateRequest.ts │ │ │ │ ├── manualActivityCreateResponse.ts │ │ │ │ ├── manualActivityCreateResponseAiSummary.ts │ │ │ │ ├── manualActivityCreateResponseAiTitle.ts │ │ │ │ ├── manualActivityCreateResponseCreatedAt.ts │ │ │ │ ├── messageTodoExtractionRequest.ts │ │ │ │ ├── messageTodoExtractionRequestMessagesItem.ts │ │ │ │ ├── messageTodoExtractionRequestParentTodoId.ts │ │ │ │ ├── messageTodoExtractionRequestTodoContext.ts │ │ │ │ ├── messageTodoExtractionResponse.ts │ │ │ │ ├── messageTodoExtractionResponseErrorMessage.ts │ │ │ │ ├── newChatRequest.ts │ │ │ │ ├── newChatRequestSessionId.ts │ │ │ │ ├── newChatResponse.ts │ │ │ │ ├── optimizeTranscriptionApiAudioOptimizePostParams.ts │ │ │ │ ├── planQuestionnaireRequest.ts │ │ │ │ ├── planQuestionnaireRequestSessionId.ts │ │ │ │ ├── planQuestionnaireRequestTodoId.ts │ │ │ │ ├── planSummaryRequest.ts │ │ │ │ ├── planSummaryRequestAnswers.ts │ │ │ │ ├── planSummaryRequestSessionId.ts │ │ │ │ ├── processInfo.ts │ │ │ │ ├── processOcrApiOcrProcessPostParams.ts │ │ │ │ ├── projectCreate.ts │ │ │ │ ├── projectCreateDefinitionOfDone.ts │ │ │ │ ├── projectCreateDescription.ts │ │ │ │ ├── projectListResponse.ts │ │ │ │ ├── projectResponse.ts │ │ │ │ ├── projectResponseDefinitionOfDone.ts │ │ │ │ ├── projectResponseDescription.ts │ │ │ │ ├── projectStatus.ts │ │ │ │ ├── projectUpdate.ts │ │ │ │ ├── projectUpdateDefinitionOfDone.ts │ │ │ │ ├── projectUpdateDescription.ts │ │ │ │ ├── projectUpdateName.ts │ │ │ │ ├── projectUpdateStatus.ts │ │ │ │ ├── saveAndInitLlmApiSaveAndInitLlmPostBody.ts │ │ │ │ ├── saveConfigApiSaveConfigPostBody.ts │ │ │ │ ├── screenshotResponse.ts │ │ │ │ ├── screenshotResponseAppName.ts │ │ │ │ ├── screenshotResponseTextContent.ts │ │ │ │ ├── screenshotResponseWindowTitle.ts │ │ │ │ ├── searchRequest.ts │ │ │ │ ├── searchRequestAppName.ts │ │ │ │ ├── searchRequestEndDate.ts │ │ │ │ ├── searchRequestQuery.ts │ │ │ │ ├── searchRequestStartDate.ts │ │ │ │ ├── semanticSearchRequest.ts │ │ │ │ ├── semanticSearchRequestFilters.ts │ │ │ │ ├── semanticSearchRequestFiltersAnyOf.ts │ │ │ │ ├── semanticSearchRequestRetrieveK.ts │ │ │ │ ├── semanticSearchResult.ts │ │ │ │ ├── semanticSearchResultMetadata.ts │ │ │ │ ├── semanticSearchResultOcrResult.ts │ │ │ │ ├── semanticSearchResultOcrResultAnyOf.ts │ │ │ │ ├── semanticSearchResultScreenshot.ts │ │ │ │ ├── semanticSearchResultScreenshotAnyOf.ts │ │ │ │ ├── statisticsResponse.ts │ │ │ │ ├── syncVectorDatabaseApiVectorSyncPostParams.ts │ │ │ │ ├── systemResourcesResponse.ts │ │ │ │ ├── systemResourcesResponseCpu.ts │ │ │ │ ├── systemResourcesResponseDisk.ts │ │ │ │ ├── systemResourcesResponseMemory.ts │ │ │ │ ├── systemResourcesResponseStorage.ts │ │ │ │ ├── systemResourcesResponseSummary.ts │ │ │ │ ├── taskBatchDeleteRequest.ts │ │ │ │ ├── taskBatchDeleteResponse.ts │ │ │ │ ├── taskCreate.ts │ │ │ │ ├── taskCreateDescription.ts │ │ │ │ ├── taskListResponse.ts │ │ │ │ ├── taskProgressListResponse.ts │ │ │ │ ├── taskProgressResponse.ts │ │ │ │ ├── taskResponse.ts │ │ │ │ ├── taskResponseDescription.ts │ │ │ │ ├── taskStatus.ts │ │ │ │ ├── taskUpdate.ts │ │ │ │ ├── taskUpdateDescription.ts │ │ │ │ ├── taskUpdateName.ts │ │ │ │ ├── taskUpdateStatus.ts │ │ │ │ ├── testAsrConfigApiTestAsrConfigPostBody.ts │ │ │ │ ├── testLlmConfigApiTestLlmConfigPostBody.ts │ │ │ │ ├── testTavilyConfigApiTestTavilyConfigPostBody.ts │ │ │ │ ├── timeAllocationResponse.ts │ │ │ │ ├── timeAllocationResponseAppDetailsItem.ts │ │ │ │ ├── timeAllocationResponseDailyDistributionItem.ts │ │ │ │ ├── todoAttachmentResponse.ts │ │ │ │ ├── todoAttachmentResponseFileSize.ts │ │ │ │ ├── todoAttachmentResponseMimeType.ts │ │ │ │ ├── todoCreate.ts │ │ │ │ ├── todoCreateCompletedAt.ts │ │ │ │ ├── todoCreateDeadline.ts │ │ │ │ ├── todoCreateDescription.ts │ │ │ │ ├── todoCreateEndTime.ts │ │ │ │ ├── todoCreateParentTodoId.ts │ │ │ │ ├── todoCreatePercentComplete.ts │ │ │ │ ├── todoCreateRrule.ts │ │ │ │ ├── todoCreateStartTime.ts │ │ │ │ ├── todoCreateUid.ts │ │ │ │ ├── todoCreateUserNotes.ts │ │ │ │ ├── todoExtractionRequest.ts │ │ │ │ ├── todoExtractionRequestScreenshotSampleRatio.ts │ │ │ │ ├── todoExtractionResponse.ts │ │ │ │ ├── todoExtractionResponseAppName.ts │ │ │ │ ├── todoExtractionResponseErrorMessage.ts │ │ │ │ ├── todoExtractionResponseEventEndTime.ts │ │ │ │ ├── todoExtractionResponseEventStartTime.ts │ │ │ │ ├── todoExtractionResponseWindowTitle.ts │ │ │ │ ├── todoItemType.ts │ │ │ │ ├── todoListResponse.ts │ │ │ │ ├── todoPriority.ts │ │ │ │ ├── todoReorderItem.ts │ │ │ │ ├── todoReorderItemParentTodoId.ts │ │ │ │ ├── todoReorderRequest.ts │ │ │ │ ├── todoResponse.ts │ │ │ │ ├── todoResponseCompletedAt.ts │ │ │ │ ├── todoResponseDeadline.ts │ │ │ │ ├── todoResponseDescription.ts │ │ │ │ ├── todoResponseEndTime.ts │ │ │ │ ├── todoResponseParentTodoId.ts │ │ │ │ ├── todoResponseRrule.ts │ │ │ │ ├── todoResponseStartTime.ts │ │ │ │ ├── todoResponseUserNotes.ts │ │ │ │ ├── todoStatus.ts │ │ │ │ ├── todoTimeInfo.ts │ │ │ │ ├── todoTimeInfoAbsoluteTime.ts │ │ │ │ ├── todoTimeInfoRelativeDays.ts │ │ │ │ ├── todoTimeInfoRelativeTime.ts │ │ │ │ ├── todoTimeInfoTimeType.ts │ │ │ │ ├── todoUpdate.ts │ │ │ │ ├── todoUpdateCompletedAt.ts │ │ │ │ ├── todoUpdateDeadline.ts │ │ │ │ ├── todoUpdateDescription.ts │ │ │ │ ├── todoUpdateEndTime.ts │ │ │ │ ├── todoUpdateName.ts │ │ │ │ ├── todoUpdateOrder.ts │ │ │ │ ├── todoUpdateParentTodoId.ts │ │ │ │ ├── todoUpdatePercentComplete.ts │ │ │ │ ├── todoUpdatePriority.ts │ │ │ │ ├── todoUpdateRelatedActivities.ts │ │ │ │ ├── todoUpdateRrule.ts │ │ │ │ ├── todoUpdateStartTime.ts │ │ │ │ ├── todoUpdateStatus.ts │ │ │ │ ├── todoUpdateTags.ts │ │ │ │ ├── todoUpdateUserNotes.ts │ │ │ │ ├── updateJournalApiJournalsJournalIdPutBody.ts │ │ │ │ ├── validationError.ts │ │ │ │ ├── validationErrorLocItem.ts │ │ │ │ ├── vectorStatsResponse.ts │ │ │ │ ├── vectorStatsResponseCollectionName.ts │ │ │ │ ├── vectorStatsResponseDocumentCount.ts │ │ │ │ ├── vectorStatsResponseError.ts │ │ │ │ ├── visionChatRequest.ts │ │ │ │ ├── visionChatRequestMaxTokens.ts │ │ │ │ ├── visionChatRequestModel.ts │ │ │ │ ├── visionChatRequestTemperature.ts │ │ │ │ ├── visionChatResponse.ts │ │ │ │ ├── visionChatResponseModel.ts │ │ │ │ ├── visionChatResponseUsageInfo.ts │ │ │ │ └── visionChatResponseUsageInfoAnyOf.ts │ │ │ ├── screenshot/ │ │ │ │ └── screenshot.ts │ │ │ ├── search/ │ │ │ │ └── search.ts │ │ │ ├── system/ │ │ │ │ └── system.ts │ │ │ ├── time-allocation/ │ │ │ │ └── time-allocation.ts │ │ │ ├── todo-extraction/ │ │ │ │ └── todo-extraction.ts │ │ │ ├── todos/ │ │ │ │ └── todos.ts │ │ │ ├── vector/ │ │ │ │ └── vector.ts │ │ │ └── vision/ │ │ │ └── vision.ts │ │ ├── hooks/ │ │ │ ├── useAutoRecording.ts │ │ │ ├── useOnboardingTour.ts │ │ │ ├── useOpenSettings.ts │ │ │ ├── usePanelLayout.ts │ │ │ ├── usePanelResize.ts │ │ │ ├── usePanelWindowResize.ts │ │ │ ├── usePanelWindowStyles.ts │ │ │ ├── useTodoCapture.ts │ │ │ └── useWindowAdaptivePanels.ts │ │ ├── i18n/ │ │ │ ├── messages/ │ │ │ │ ├── en.json │ │ │ │ └── zh.json │ │ │ └── request.ts │ │ ├── island/ │ │ │ └── types.ts │ │ ├── plugins/ │ │ │ └── registry.ts │ │ ├── query/ │ │ │ ├── activities.ts │ │ │ ├── automation.ts │ │ │ ├── chat.ts │ │ │ ├── config.ts │ │ │ ├── cost.ts │ │ │ ├── index.ts │ │ │ ├── journals.ts │ │ │ ├── keys.ts │ │ │ ├── provider.tsx │ │ │ └── todos.ts │ │ ├── reminders.ts │ │ ├── services/ │ │ │ └── notification-poller.ts │ │ ├── store/ │ │ │ ├── activity-store.ts │ │ │ ├── audio-recording-store.ts │ │ │ ├── breakdown-store.ts │ │ │ ├── chat-store.ts │ │ │ ├── color-theme.ts │ │ │ ├── journal-store.ts │ │ │ ├── locale.ts │ │ │ ├── notification-store.ts │ │ │ ├── onboarding-store.ts │ │ │ ├── theme.ts │ │ │ ├── todo-store.ts │ │ │ ├── ui-store/ │ │ │ │ ├── index.ts │ │ │ │ ├── layout-actions.ts │ │ │ │ ├── layout-presets.ts │ │ │ │ ├── storage.ts │ │ │ │ ├── store.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ └── ui-store.ts │ │ ├── toast.ts │ │ ├── types/ │ │ │ └── index.ts │ │ ├── utils/ │ │ │ ├── electron-api.ts │ │ │ ├── electron.ts │ │ │ ├── platform.ts │ │ │ └── time.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── orval.config.ts │ ├── package.json │ ├── pnpm-workspace.yaml │ ├── postcss.config.mjs │ ├── public/ │ │ ├── app-icons/ │ │ │ └── README.md │ │ └── free-todo-logos/ │ │ └── favicon/ │ │ └── site.webmanifest │ ├── scripts/ │ │ ├── build-electron.js │ │ ├── check_code_lines.js │ │ ├── check_rust_code_lines.js │ │ ├── collect-tauri-artifacts.js │ │ ├── copy-missing-deps.js │ │ ├── dev-with-auto-port.js │ │ ├── electron-dev-electron.ps1 │ │ ├── electron-dev.ps1 │ │ ├── resolve-symlinks.js │ │ ├── tauri-copy-resources.js │ │ └── tauri-prebuild.js │ ├── src-tauri/ │ │ ├── .tauri-lint-dist/ │ │ │ └── .gitkeep │ │ ├── Cargo.toml │ │ ├── PACKAGING_GUIDE.md │ │ ├── build.rs │ │ ├── icons/ │ │ │ ├── android/ │ │ │ │ ├── mipmap-anydpi-v26/ │ │ │ │ │ └── ic_launcher.xml │ │ │ │ └── values/ │ │ │ │ └── ic_launcher_background.xml │ │ │ └── icon.icns │ │ ├── rust-toolchain.toml │ │ ├── rustfmt.toml │ │ ├── src/ │ │ │ ├── backend.rs │ │ │ ├── backend_log.rs │ │ │ ├── backend_paths.rs │ │ │ ├── backend_proxy.rs │ │ │ ├── backend_python.rs │ │ │ ├── backend_support.rs │ │ │ ├── config.rs │ │ │ ├── lib.rs │ │ │ ├── main.rs │ │ │ ├── nextjs.rs │ │ │ ├── shortcut.rs │ │ │ └── tray.rs │ │ ├── tauri.conf.json │ │ ├── tauri.island.pyinstaller.json │ │ ├── tauri.island.script.json │ │ ├── tauri.lint.json │ │ ├── tauri.web.pyinstaller.json │ │ └── tauri.web.script.json │ ├── tailwind.config.ts │ └── tsconfig.json ├── lifetrace/ │ ├── __init__.py │ ├── alembic.ini │ ├── config/ │ │ ├── default_config.yaml │ │ ├── prompt.yaml │ │ ├── prompts/ │ │ │ ├── agno_tools/ │ │ │ │ ├── en/ │ │ │ │ │ ├── breakdown.yaml │ │ │ │ │ ├── conflict.yaml │ │ │ │ │ ├── instructions.yaml │ │ │ │ │ ├── stats.yaml │ │ │ │ │ ├── tags.yaml │ │ │ │ │ ├── time.yaml │ │ │ │ │ └── todo.yaml │ │ │ │ └── zh/ │ │ │ │ ├── breakdown.yaml │ │ │ │ ├── conflict.yaml │ │ │ │ ├── instructions.yaml │ │ │ │ ├── stats.yaml │ │ │ │ ├── tags.yaml │ │ │ │ ├── time.yaml │ │ │ │ └── todo.yaml │ │ │ ├── audio.yaml │ │ │ ├── chat.yaml │ │ │ ├── llm.yaml │ │ │ ├── plan.yaml │ │ │ ├── rag.yaml │ │ │ ├── search.yaml │ │ │ ├── summary.yaml │ │ │ └── todo.yaml │ │ └── rapidocr_config.yaml │ ├── core/ │ │ ├── __init__.py │ │ ├── config_watcher.py │ │ ├── dependencies.py │ │ ├── lazy_services.py │ │ └── module_registry.py │ ├── docs/ │ │ └── MIGRATION_GUIDE.md │ ├── jobs/ │ │ ├── activity_aggregator.py │ │ ├── clean_data.py │ │ ├── deadline_reminder.py │ │ ├── job_manager.py │ │ ├── ocr.py │ │ ├── ocr_config.py │ │ ├── ocr_processor.py │ │ ├── proactive_ocr/ │ │ │ ├── __init__.py │ │ │ ├── capture.py │ │ │ ├── models.py │ │ │ ├── ocr_engine.py │ │ │ ├── priors/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── feishu.py │ │ │ │ ├── registry.py │ │ │ │ └── wechat.py │ │ │ ├── roi.py │ │ │ ├── router.py │ │ │ └── service.py │ │ ├── recorder.py │ │ ├── recorder_blacklist.py │ │ ├── recorder_capture.py │ │ ├── recorder_config.py │ │ ├── scheduler.py │ │ └── todo_recorder.py │ ├── llm/ │ │ ├── activity_summary_service.py │ │ ├── agent_service.py │ │ ├── agno_agent.py │ │ ├── agno_tools/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── toolkit.py │ │ │ └── tools/ │ │ │ ├── __init__.py │ │ │ ├── breakdown_tools.py │ │ │ ├── conflict_tools.py │ │ │ ├── stats_tools.py │ │ │ ├── tag_tools.py │ │ │ ├── time_tools.py │ │ │ └── todo_tools.py │ │ ├── auto_todo_detection_service.py │ │ ├── context_builder.py │ │ ├── event_summary_clustering.py │ │ ├── event_summary_config.py │ │ ├── event_summary_ocr.py │ │ ├── event_summary_service.py │ │ ├── journal_generation_service.py │ │ ├── llm_client.py │ │ ├── llm_client_intent.py │ │ ├── llm_client_query.py │ │ ├── llm_client_vision.py │ │ ├── ocr_todo_extractor.py │ │ ├── rag_fallback.py │ │ ├── rag_service.py │ │ ├── rag_stream.py │ │ ├── retrieval_service.py │ │ ├── tavily_client.py │ │ ├── todo_extraction_service.py │ │ ├── tools/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── registry.py │ │ │ └── web_search_tool.py │ │ ├── vector_db.py │ │ ├── vector_service.py │ │ └── web_search_service.py │ ├── migrations/ │ │ ├── MIGRATIONS.md │ │ ├── README │ │ ├── env.py │ │ ├── re_extract_all_transcriptions.py │ │ ├── script.py.mako │ │ └── versions/ │ │ ├── 034079ad387f_add_segment_timestamps.py │ │ ├── 4ca5036ec7c8_add_context_to_chats.py │ │ ├── add_automation_tasks_001.py │ │ ├── add_file_path_to_audio_recordings.py │ │ ├── add_icalendar_fields_v2_001.py │ │ ├── add_journal_panel_001.py │ │ ├── add_optimized_extraction_fields.py │ │ ├── add_text_hash_to_ocr_results.py │ │ ├── add_todo_attachment_source_001.py │ │ ├── add_todo_end_time_001.py │ │ ├── add_todo_reminder_offsets_001.py │ │ ├── add_todo_timezone_all_day_001.py │ │ ├── b53d9b7c8e21_add_uid_to_journals.py │ │ ├── cc25001eb19c_initial_schema.py │ │ ├── cff6e6d7a3cf_merge_heads_segment_timestamps_and_.py │ │ ├── d2f7a9c6b1a4_add_icalendar_fields_to_todos.py │ │ ├── merge_automation_ical_001.py │ │ ├── merge_heads_journal_todo_20260203.py │ │ ├── merge_heads_todos_20260131.py │ │ ├── merge_journal_uid_automation_20260204.py │ │ └── remove_project_task_tables.py │ ├── models/ │ │ ├── ch_PP-OCRv4_det_infer.onnx │ │ ├── ch_PP-OCRv4_rec_infer.onnx │ │ └── ch_ppocr_mobile_v2.0_cls_infer.onnx │ ├── observability/ │ │ ├── __init__.py │ │ ├── config.py │ │ ├── exporters/ │ │ │ ├── __init__.py │ │ │ ├── file_exporter.py │ │ │ └── phoenix_exporter.py │ │ └── setup.py │ ├── pyinstaller.spec │ ├── repositories/ │ │ ├── __init__.py │ │ ├── interfaces.py │ │ ├── sql_activity_repository.py │ │ ├── sql_chat_repository.py │ │ ├── sql_event_repository.py │ │ ├── sql_journal_repository.py │ │ └── sql_todo_repository.py │ ├── routers/ │ │ ├── activity.py │ │ ├── audio.py │ │ ├── audio_ws.py │ │ ├── audio_ws_handler.py │ │ ├── audio_ws_segment.py │ │ ├── automation.py │ │ ├── chat/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── context.py │ │ │ ├── core.py │ │ │ ├── helpers.py │ │ │ ├── message_todo_extraction.py │ │ │ ├── misc.py │ │ │ ├── modes/ │ │ │ │ ├── __init__.py │ │ │ │ ├── agent.py │ │ │ │ ├── agno.py │ │ │ │ ├── dify.py │ │ │ │ └── web_search.py │ │ │ └── plan.py │ │ ├── chat.py │ │ ├── config.py │ │ ├── cost_tracking.py │ │ ├── event.py │ │ ├── floating_capture.py │ │ ├── health.py │ │ ├── journal.py │ │ ├── logs.py │ │ ├── notification.py │ │ ├── ocr.py │ │ ├── proactive_ocr.py │ │ ├── rag.py │ │ ├── scheduler.py │ │ ├── screenshot.py │ │ ├── search.py │ │ ├── system.py │ │ ├── time_allocation.py │ │ ├── todo.py │ │ ├── todo_extraction.py │ │ ├── vector.py │ │ └── vision.py │ ├── schemas/ │ │ ├── __init__.py │ │ ├── activity.py │ │ ├── automation.py │ │ ├── chat.py │ │ ├── event.py │ │ ├── floating_capture.py │ │ ├── journal.py │ │ ├── message_todo_extraction.py │ │ ├── screenshot.py │ │ ├── search.py │ │ ├── stats.py │ │ ├── system.py │ │ ├── todo.py │ │ ├── todo_extraction.py │ │ ├── vector.py │ │ └── vision.py │ ├── scripts/ │ │ ├── add_file_path_column.py │ │ ├── build-backend.ps1 │ │ ├── build-backend.sh │ │ ├── check_code_lines.py │ │ ├── fix_audio_recordings_table.py │ │ ├── fix_transcriptions_table.py │ │ └── start_backend.py │ ├── server.py │ ├── services/ │ │ ├── __init__.py │ │ ├── activity_service.py │ │ ├── asr_client.py │ │ ├── asr_client_dashscope.py │ │ ├── audio_extraction_service.py │ │ ├── audio_service.py │ │ ├── automation_task_service.py │ │ ├── chat_service.py │ │ ├── config_service.py │ │ ├── dify_client.py │ │ ├── event_service.py │ │ ├── icalendar_service.py │ │ ├── journal_service.py │ │ └── todo_service.py │ ├── storage/ │ │ ├── __init__.py │ │ ├── activity_manager.py │ │ ├── automation_task_manager.py │ │ ├── chat_manager.py │ │ ├── database.py │ │ ├── database_base.py │ │ ├── event_manager.py │ │ ├── event_queries.py │ │ ├── event_stats.py │ │ ├── journal_manager.py │ │ ├── migrations/ │ │ │ └── journal_migration.py │ │ ├── models.py │ │ ├── notification_storage.py │ │ ├── ocr_manager.py │ │ ├── screenshot_manager.py │ │ ├── sql_utils.py │ │ ├── stats_manager.py │ │ ├── todo_manager.py │ │ ├── todo_manager_attachments.py │ │ ├── todo_manager_ical.py │ │ └── todo_manager_utils.py │ └── util/ │ ├── app_utils.py │ ├── base_paths.py │ ├── image_utils.py │ ├── language.py │ ├── logging_config.py │ ├── path_utils.py │ ├── prompt_loader.py │ ├── query_parser.py │ ├── settings.py │ ├── time_parser.py │ ├── time_utils.py │ ├── token_usage_logger.py │ └── utils.py ├── pyproject.toml ├── pyrightconfig.json ├── requirements-runtime.txt ├── scripts/ │ ├── git-hooks/ │ │ └── post-checkout │ ├── install.ps1 │ ├── install.sh │ ├── link_worktree_deps.ps1 │ ├── link_worktree_deps.sh │ ├── link_worktree_deps_here.ps1 │ ├── link_worktree_deps_here.sh │ ├── new_worktree.py │ ├── precommit_clippy.py │ ├── precommit_rustfmt.py │ ├── setup_hooks_here.ps1 │ └── setup_hooks_here.sh └── tests/ ├── conftest.py ├── test_icalendar_service.py ├── test_todo_serialization.py └── test_todo_service_mapping.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cursor/commands/agno_agent.md ================================================ # Agno Agent Development Quick Commands ## Overview This guide covers development of **Agno Agent Tools** - the AI-powered todo management toolkit based on the [Agno framework](https://docs.agno.com/). The FreeTodoToolkit provides a set of tools for the Agno Agent to manage todos. For the complete list of available tools, please refer to the source code in `llm/agno_tools/tools/` directory. --- ## 🏗️ Architecture ### Directory Structure ``` lifetrace/ ├── config/prompts/agno_tools/ # Localized messages & prompts │ ├── zh/ # Chinese messages │ └── en/ # English messages (same structure) │ ├── llm/agno_tools/ # Python implementation │ ├── __init__.py # Module exports │ ├── base.py # Message loader (AgnoToolsMessageLoader) │ ├── toolkit.py # Main FreeTodoToolkit class │ └── tools/ # Individual tool implementations (organized by category) │ └── observability/ # Agent monitoring (Phoenix + OpenInference) ├── __init__.py # Module exports ├── config.py # Observability configuration ├── setup.py # Initialization entry point └── exporters/ ├── __init__.py └── file_exporter.py # Local JSON file exporter ``` ### Design Patterns - **Mixin Pattern**: Each tool category is a separate mixin class - **Composition**: FreeTodoToolkit inherits from all mixins + Agno Toolkit - **i18n**: Messages loaded from language-specific YAML files - **Lazy Loading**: Database and LLM clients initialized on-demand --- ## 🔧 Adding a New Tool ### Step 1: Add Messages (Both Languages) Create or update YAML files in `config/prompts/agno_tools/zh/` and `en/`: ```yaml # config/prompts/agno_tools/zh/my_tool.yaml my_tool_success: "操作成功: {result}" my_tool_failed: "操作失败: {error}" my_tool_prompt: | 这是给 LLM 的提示词模板。 参数: {param} ``` ```yaml # config/prompts/agno_tools/en/my_tool.yaml my_tool_success: "Operation successful: {result}" my_tool_failed: "Operation failed: {error}" my_tool_prompt: | This is a prompt template for LLM. Parameter: {param} ``` ### Step 2: Create Tool Mixin Create a new file in `llm/agno_tools/tools/`: ```python # llm/agno_tools/tools/my_tools.py """My Tools - Description of what these tools do.""" from __future__ import annotations from typing import TYPE_CHECKING from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger if TYPE_CHECKING: from lifetrace.repositories.sql_todo_repository import SqlTodoRepository logger = get_logger() class MyTools: """My tools mixin""" lang: str todo_repo: "SqlTodoRepository" # If needed def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def my_tool_method(self, param: str) -> str: """Tool description for LLM to understand when to use it Args: param: Description of the parameter Returns: Result message """ try: # Implementation result = f"processed {param}" return self._msg("my_tool_success", result=result) except Exception as e: logger.error(f"Failed: {e}") return self._msg("my_tool_failed", error=str(e)) ``` ### Step 3: Register in Toolkit Update `llm/agno_tools/tools/__init__.py`: ```python from lifetrace.llm.agno_tools.tools.my_tools import MyTools __all__ = [..., "MyTools"] ``` Update `llm/agno_tools/toolkit.py`: ```python from lifetrace.llm.agno_tools.tools import ( ..., MyTools, ) class FreeTodoToolkit( ..., MyTools, # Add mixin Toolkit, ): def __init__(self, lang: str = "en", **kwargs): ... tools = [ ..., self.my_tool_method, # Register tool ] ``` --- ## 📝 Message Configuration ### YAML Structure Messages are organized by functionality in `config/prompts/agno_tools/{lang}/` directory. Each YAML file corresponds to a category of messages. ### Message Format - Use `{placeholder}` for variable substitution - Multi-line prompts use YAML `|` syntax - Keep messages concise and informative ```yaml # Simple message with placeholder create_success: "Created todo #{id}: {name}" # Multi-line prompt breakdown_prompt: | Break down this task into subtasks. Task: {task_description} Return JSON format. ``` ### Accessing Messages ```python # In tool methods def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) # Usage return self._msg("create_success", id=123, name="Buy groceries") ``` --- ## 🌐 Internationalization ### Language Selection Language is passed through the call chain: ``` Request Header (Accept-Language) ↓ Chat Router (get_request_language) ↓ AgnoAgentService(lang=lang) ↓ FreeTodoToolkit(lang=lang) ↓ AgnoToolsMessageLoader(lang) ``` ### Adding a New Language 1. Create new directory: `config/prompts/agno_tools/{lang}/` 2. Copy all YAML files from `en/` 3. Translate all messages 4. The loader will automatically detect the new language --- ## 🧪 Testing Tools ### Quick Test Script ```python from lifetrace.llm.agno_tools import FreeTodoToolkit # Test Chinese toolkit_zh = FreeTodoToolkit(lang="zh") print(toolkit_zh.list_todos(status="active", limit=5)) # Test English toolkit_en = FreeTodoToolkit(lang="en") print(toolkit_en.list_todos(status="active", limit=5)) ``` ### Running Tests ```bash uv run python -c " from lifetrace.llm.agno_tools import FreeTodoToolkit tk = FreeTodoToolkit(lang='zh') print(tk.parse_time('明天下午3点')) " ``` --- ## 📊 Observability (Agent Monitoring) The Agno Agent integrates with [Arize Phoenix](https://arize.com/docs/phoenix) + [OpenInference](https://github.com/arize-ai/openinference) for tracing and monitoring. ### Features - **Local JSON Export**: Cursor-friendly trace files for AI analysis - **Phoenix UI**: Optional web-based visualization - **Minimal Terminal Output**: One-line summary per trace ### Configuration In `config/config.yaml`: ```yaml observability: enabled: true # Enable observability mode: both # local | phoenix | both local: traces_dir: traces/ # Trace file directory max_files: 100 # Max files to keep pretty_print: true # Format JSON for readability phoenix: endpoint: http://localhost:6006 project_name: freetodo-agent terminal: summary_only: true # One-line output (recommended) ``` ### Trace File Format Each agent run generates a JSON file in `data/traces/`: ```json { "trace_id": "e078e147372a", "timestamp": "2026-01-23T08:23:48.377470+00:00", "duration_ms": 26910.94, "agent": "breakdown_task", "input": "{\"task_description\": \"Make a video\"}", "output_preview": "Task breakdown:\n1. Define topic...", "tool_calls": [ { "name": "breakdown_task", "args": {"task_description": "Make a video"}, "result_preview": "Task breakdown...", "duration_ms": 26910.94 } ], "llm_calls": [], "status": "success", "span_count": 1 } ``` ### Terminal Output With `summary_only: true`: ``` [Trace] e078e147372a | 1 tools | 26.91s | traces/20260123_082348_e078e147372a.json ``` ### Using Phoenix UI (Optional) ```bash # Start Phoenix server uv run phoenix serve # Access http://localhost:6006 ``` --- ## ✅ Development Checklist When adding new tools: - [ ] Create YAML messages in both `zh/` and `en/` directories - [ ] Create tool mixin class with proper type hints - [ ] Add docstrings for LLM to understand tool usage - [ ] Use `_msg()` for all user-facing messages - [ ] Handle exceptions and return error messages - [ ] Register tool in `tools/__init__.py` - [ ] Add mixin to `FreeTodoToolkit` class - [ ] Register method in `tools` list - [ ] Test with both languages ================================================ FILE: .cursor/commands/agno_agent_CN.md ================================================ # Agno Agent 开发快捷命令 ## 概述 本指南涵盖 **Agno Agent Tools** 的开发 - 基于 [Agno 框架](https://docs.agno.com/) 的 AI 待办管理工具包。 FreeTodoToolkit 为 Agno Agent 提供一系列工具,用于管理待办事项。具体工具列表请查阅 `llm/agno_tools/tools/` 目录下的源代码。 --- ## 🏗️ 架构 ### 目录结构 ``` lifetrace/ ├── config/prompts/agno_tools/ # 本地化消息和提示词 │ ├── zh/ # 中文消息 │ └── en/ # 英文消息(结构相同) │ ├── llm/agno_tools/ # Python 实现 │ ├── __init__.py # 模块导出 │ ├── base.py # 消息加载器 (AgnoToolsMessageLoader) │ ├── toolkit.py # 主 FreeTodoToolkit 类 │ └── tools/ # 各工具实现(按功能分类) │ └── observability/ # Agent 监控(Phoenix + OpenInference) ├── __init__.py # 模块导出 ├── config.py # 观测配置 ├── setup.py # 初始化入口 └── exporters/ ├── __init__.py └── file_exporter.py # 本地 JSON 文件导出器 ``` ### 设计模式 - **Mixin 模式**:每个工具类别是独立的 mixin 类 - **组合模式**:FreeTodoToolkit 继承所有 mixin + Agno Toolkit - **国际化**:消息从语言特定的 YAML 文件加载 - **懒加载**:数据库和 LLM 客户端按需初始化 --- ## 🔧 添加新工具 ### 步骤 1:添加消息(中英文) 在 `config/prompts/agno_tools/zh/` 和 `en/` 中创建或更新 YAML 文件: ```yaml # config/prompts/agno_tools/zh/my_tool.yaml my_tool_success: "操作成功: {result}" my_tool_failed: "操作失败: {error}" my_tool_prompt: | 这是给 LLM 的提示词模板。 参数: {param} ``` ```yaml # config/prompts/agno_tools/en/my_tool.yaml my_tool_success: "Operation successful: {result}" my_tool_failed: "Operation failed: {error}" my_tool_prompt: | This is a prompt template for LLM. Parameter: {param} ``` ### 步骤 2:创建工具 Mixin 在 `llm/agno_tools/tools/` 中创建新文件: ```python # llm/agno_tools/tools/my_tools.py """My Tools - 这些工具的功能描述""" from __future__ import annotations from typing import TYPE_CHECKING from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger if TYPE_CHECKING: from lifetrace.repositories.sql_todo_repository import SqlTodoRepository logger = get_logger() class MyTools: """My tools mixin""" lang: str todo_repo: "SqlTodoRepository" # 如果需要 def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def my_tool_method(self, param: str) -> str: """工具描述,让 LLM 理解何时使用此工具 Args: param: 参数描述 Returns: 结果消息 """ try: # 实现逻辑 result = f"processed {param}" return self._msg("my_tool_success", result=result) except Exception as e: logger.error(f"Failed: {e}") return self._msg("my_tool_failed", error=str(e)) ``` ### 步骤 3:注册到 Toolkit 更新 `llm/agno_tools/tools/__init__.py`: ```python from lifetrace.llm.agno_tools.tools.my_tools import MyTools __all__ = [..., "MyTools"] ``` 更新 `llm/agno_tools/toolkit.py`: ```python from lifetrace.llm.agno_tools.tools import ( ..., MyTools, ) class FreeTodoToolkit( ..., MyTools, # 添加 mixin Toolkit, ): def __init__(self, lang: str = "en", **kwargs): ... tools = [ ..., self.my_tool_method, # 注册工具 ] ``` --- ## 📝 消息配置 ### YAML 结构 消息按功能组织在 `config/prompts/agno_tools/{lang}/` 目录下。每个 YAML 文件对应一类功能的消息。 ### 消息格式 - 使用 `{placeholder}` 进行变量替换 - 多行提示词使用 YAML `|` 语法 - 保持消息简洁且信息丰富 ```yaml # 带占位符的简单消息 create_success: "成功创建待办 #{id}: {name}" # 多行提示词 breakdown_prompt: | 请将此任务拆解为子任务。 任务: {task_description} 返回 JSON 格式。 ``` ### 访问消息 ```python # 在工具方法中 def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) # 使用 return self._msg("create_success", id=123, name="买菜") ``` --- ## 🌐 国际化 ### 语言选择 语言通过调用链传递: ``` 请求头 (Accept-Language) ↓ Chat Router (get_request_language) ↓ AgnoAgentService(lang=lang) ↓ FreeTodoToolkit(lang=lang) ↓ AgnoToolsMessageLoader(lang) ``` ### 添加新语言 1. 创建新目录:`config/prompts/agno_tools/{lang}/` 2. 从 `en/` 复制所有 YAML 文件 3. 翻译所有消息 4. 加载器会自动检测新语言 --- ## 🧪 测试工具 ### 快速测试脚本 ```python from lifetrace.llm.agno_tools import FreeTodoToolkit # 测试中文 toolkit_zh = FreeTodoToolkit(lang="zh") print(toolkit_zh.list_todos(status="active", limit=5)) # 测试英文 toolkit_en = FreeTodoToolkit(lang="en") print(toolkit_en.list_todos(status="active", limit=5)) ``` ### 运行测试 ```bash uv run python -c " from lifetrace.llm.agno_tools import FreeTodoToolkit tk = FreeTodoToolkit(lang='zh') print(tk.parse_time('明天下午3点')) " ``` --- ## 📊 可观测性(Agent 监控) Agno Agent 集成了 [Arize Phoenix](https://arize.com/docs/phoenix) + [OpenInference](https://github.com/arize-ai/openinference) 进行链路追踪和监控。 ### 功能特性 - **本地 JSON 导出**:Cursor 友好的 trace 文件,便于 AI 分析 - **Phoenix UI**:可选的 Web 可视化界面 - **精简终端输出**:每次 trace 仅输出一行摘要 ### 配置方法 在 `config/config.yaml` 中: ```yaml observability: enabled: true # 启用观测功能 mode: both # local | phoenix | both local: traces_dir: traces/ # trace 文件目录 max_files: 100 # 最大保留文件数 pretty_print: true # 格式化 JSON 便于阅读 phoenix: endpoint: http://localhost:6006 project_name: freetodo-agent terminal: summary_only: true # 仅输出一行摘要(推荐) ``` ### Trace 文件格式 每次 Agent 运行会在 `data/traces/` 生成一个 JSON 文件: ```json { "trace_id": "e078e147372a", "timestamp": "2026-01-23T08:23:48.377470+00:00", "duration_ms": 26910.94, "agent": "breakdown_task", "input": "{\"task_description\": \"做视频\"}", "output_preview": "任务拆解结果:\n1. 确定视频主题...", "tool_calls": [ { "name": "breakdown_task", "args": {"task_description": "做视频"}, "result_preview": "任务拆解结果...", "duration_ms": 26910.94 } ], "llm_calls": [], "status": "success", "span_count": 1 } ``` ### 终端输出 启用 `summary_only: true` 时: ``` [Trace] e078e147372a | 1 tools | 26.91s | traces/20260123_082348_e078e147372a.json ``` ### 使用 Phoenix UI(可选) ```bash # 启动 Phoenix 服务 uv run phoenix serve # 访问 http://localhost:6006 ``` --- ## ✅ 开发检查清单 添加新工具时: - [ ] 在 `zh/` 和 `en/` 目录中创建 YAML 消息 - [ ] 创建带有正确类型提示的工具 mixin 类 - [ ] 添加文档字符串让 LLM 理解工具用途 - [ ] 所有用户可见消息使用 `_msg()` - [ ] 处理异常并返回错误消息 - [ ] 在 `tools/__init__.py` 中注册工具 - [ ] 将 mixin 添加到 `FreeTodoToolkit` 类 - [ ] 在 `tools` 列表中注册方法 - [ ] 使用两种语言测试 ================================================ FILE: .cursor/commands/backend.md ================================================ # Backend Development Quick Commands (lifetrace version) ## Tech Stack Information - **Framework**: FastAPI + Uvicorn (async web framework) - **Language**: Python 3.12 - **ORM**: SQLAlchemy 2.x + SQLModel - **Database Migration**: Alembic - **Data Validation**: Pydantic 2.x - **Configuration Management**: Dynaconf (supports YAML hot reload) - **Logging**: Loguru - **Scheduler**: APScheduler (background task scheduling) - **OCR**: RapidOCR (local OCR recognition) - **Vector Database**: ChromaDB (optional, for semantic search) - **Text Embedding**: sentence-transformers (optional) - **LLM**: OpenAI-compatible API - **Package Manager**: uv (recommended) - **Code Quality**: Ruff (lint/format/check) --- ## 🏗️ Project Architecture ``` lifetrace/ ├── server.py # FastAPI application entry point ├── config/ # Configuration files directory │ ├── config.yaml # User configuration │ ├── default_config.yaml # Default configuration │ └── prompt.yaml # LLM Prompt templates ├── routers/ # API routing layer ├── services/ # Business service layer ├── repositories/ # Data access layer (Repository pattern) ├── schemas/ # Pydantic data models ├── storage/ # Data storage layer (SQLAlchemy models) ├── llm/ # LLM and AI services ├── jobs/ # Background tasks ├── core/ # Core dependencies and lazy-loaded services └── util/ # Utility functions ``` ### Layered Architecture Overview - **Router Layer**: Handles HTTP requests, parameter validation, calls Service layer - **Service Layer**: Business logic, orchestrates multiple Repository operations - **Repository Layer**: Data access abstraction, encapsulates database queries - **Schema Layer**: Request/response Pydantic models - **Storage Layer**: SQLAlchemy ORM model definitions --- ## 🔧 Route Development ### Creating New API Routes Create new routes in the `lifetrace/routers/` directory: - Use `APIRouter` to define route prefixes and tags - Follow RESTful API design principles - Use dependency injection to get database sessions - Add complete type annotations and docstrings ### RESTful Route Conventions - `GET /api/{resource}` - Get list - `GET /api/{resource}/{id}` - Get single resource - `POST /api/{resource}` - Create resource - `PUT /api/{resource}/{id}` - Full update - `PATCH /api/{resource}/{id}` - Partial update - `DELETE /api/{resource}/{id}` - Delete resource ### Registering Routes Import and register new routes in `server.py`: - Use `app.include_router(xxx.router)` to register - Routes organized by functional modules --- ## 📦 Data Models ### Pydantic Schema Conventions Create data models in the `lifetrace/schemas/` directory: - Use Pydantic v2 syntax - Distinguish models for different scenarios: `Create`, `Update`, `Response`, etc. - Use `Field()` to add validation rules and descriptions - Enable `model_config = ConfigDict(from_attributes=True)` to support ORM conversion ### Common Model Patterns - `{Resource}Create` - Request body for creation - `{Resource}Update` - Request body for updates (fields typically Optional) - `{Resource}Response` - API response format - `{Resource}List` - List response (includes pagination info) ### SQLAlchemy Model Conventions Define database tables in `lifetrace/storage/models.py`: - Use SQLAlchemy 2.x declarative syntax - Add indexes for commonly queried fields - Use relationships to define table associations - Add `created_at` and `updated_at` timestamp fields --- ## 🗄️ Repository Layer ### Creating Repositories Create data access classes in the `lifetrace/repositories/` directory: - Inherit or implement interfaces defined in `interfaces.py` - Encapsulate all database query logic - Use async methods (`async def`) - Support parameterized queries to prevent SQL injection ### Repository Naming Conventions - `sql_{resource}_repository.py` - SQL database implementation - Class names use `{Resource}Repository` format --- ## 🎯 Service Layer ### Creating Services Create business services in the `lifetrace/services/` directory: - Implement complex business logic - Orchestrate multiple Repository operations - Handle transaction boundaries - Call external services (LLM, OCR, etc.) ### Service Conventions - Class names use `{Resource}Service` format - Get Repository instances through dependency injection - Use custom Exception classes for business exceptions - Add detailed logging --- ## 🤖 LLM Services ### LLM Client Usage The project uses OpenAI-compatible APIs, encapsulated via `llm/llm_client.py`: - Supports Alibaba Cloud Tongyi Qianwen, OpenAI, Claude, etc. - Configuration managed through the `llm` section in `config/config.yaml` - Supports streaming responses (SSE) ### RAG Service `llm/rag_service.py` provides Retrieval-Augmented Generation: - Smart time parsing (e.g., "last week", "yesterday") - Hybrid retrieval strategy (vector search + full-text search) - Context compression and ranking ### Prompt Management Prompt templates are stored in `config/prompt.yaml`: - Use YAML format for easy maintenance - Support variable interpolation - Organized by functional modules ### Agno Agent `llm/agno_agent.py` provides AI-powered todo management via [Agno framework](https://docs.agno.com/): - FreeTodoToolkit with 14 tools (CRUD, breakdown, time parsing, etc.) - Internationalization support (zh/en) - Mixin-based architecture for extensibility See `.cursor/commands/agno_agent.md` for detailed development guide. --- ## ⏰ Background Tasks ### Task Scheduling Use APScheduler to manage background tasks: - Tasks defined in `lifetrace/jobs/` directory - Managed uniformly through `job_manager.py` - Supports scheduled tasks and interval tasks ### Task Types - **recorder**: Screen recorder, scheduled screenshots - **ocr**: OCR processor, processes screenshots awaiting recognition --- ## ⚙️ Configuration Management ### Configuration File Structure - `config/default_config.yaml` - Default configuration (do not modify) - `config/config.yaml` - User configuration (overrides default values) - Uses Dynaconf to support configuration hot reload ### Accessing Configuration Access through the `settings` object in `util/settings.py`: - `settings.server.port` - Access nested configuration - `settings.get("key", default)` - Access with default value ### Configuration Hot Reload The following configurations support hot reload (no restart required): - LLM configuration - Recording configuration - OCR configuration --- ## 📝 Logging ### Using Loguru Import logger from `util/logging_config.py`: - `logger.info()` - General information - `logger.warning()` - Warning information - `logger.error()` - Error information - `logger.debug()` - Debug information ### Logging Conventions - Critical operations must be logged - Exceptions must log full stack traces - Sensitive information (API Keys, etc.) must be sanitized - Use structured logging for easier analysis --- ## 🗃️ Database Migration ### Using Alembic The project uses Alembic to manage database migrations: - Configuration file: `alembic.ini` - Migration scripts: `migrations/versions/` ### Common Commands - `alembic revision --autogenerate -m "description"` - Generate migration script - `alembic upgrade head` - Apply all migrations - `alembic downgrade -1` - Rollback one version - `alembic history` - View migration history --- ## 🧪 Code Quality ### Ruff Checking and Formatting The project uses Ruff for code checking and formatting: - `uv run ruff check .` - Check code - `uv run ruff check --fix .` - Auto-fix issues - `uv run ruff format .` - Format code ### Code Standards - Follow PEP 8 style guide - Maximum 100 characters per line - Maximum 500 lines per file (warning threshold 700 lines) - Maximum 50 statements per function - Cyclomatic complexity should not exceed 15 --- ## 🔐 Error Handling ### HTTP Exceptions Use FastAPI's `HTTPException`: - `400` - Request parameter error - `404` - Resource not found - `422` - Validation error (automatically handled by Pydantic) - `500` - Internal server error ### Exception Handling Conventions - Catch specific exceptions, avoid catching all exceptions - Log errors with context - Return user-friendly error messages - Do not expose sensitive information to clients --- ## 🚀 Performance Optimization ### Database Query Optimization - Use `selectinload` to avoid N+1 queries - Add indexes for commonly queried fields - Use pagination to limit returned data - Use batch operations instead of looping single operations ### Async Processing - Use `async/await` for I/O operations - Use async sessions for database queries - Use async clients for external API calls ### Lazy Loading - Large services (vector service, OCR) use lazy loading - Initialize on-demand through `core/lazy_services.py` - Avoid loading all dependencies at startup --- ## 📡 API and Frontend Interaction ### Naming Style Conversion Backend uses `snake_case`, frontend uses `camelCase`: - Frontend fetcher automatically converts - Backend Schema uniformly uses `snake_case` - OpenAPI Schema automatically generated by FastAPI ### Frontend Code Generation Frontend uses Orval to automatically generate API code from OpenAPI Schema: - After backend API changes, frontend runs `pnpm orval` to regenerate - Ensure OpenAPI Schema is complete and accurate --- ## 📋 Dependency Management ### Using uv The project uses uv as package manager: - `uv sync` - Sync dependencies - `uv add ` - Add dependency - `uv remove ` - Remove dependency - `uv run ` - Run command in virtual environment ### Dependency Groups - Main dependencies: `dependencies` in `pyproject.toml` - Development dependencies: `dependency-groups.dev` - Optional dependencies: `dependency-groups.vector` (vector search functionality) --- ## 🔍 Debugging and Troubleshooting ### Starting Development Server - `python -m lifetrace.server` - Direct start - `uvicorn lifetrace.server:app --reload` - Hot reload mode ### API Documentation - Swagger UI: `http://localhost:8001/docs` - ReDoc: `http://localhost:8001/redoc` - OpenAPI JSON: `http://localhost:8001/openapi.json` ### Log Viewing - Log files located at `lifetrace/data/logs/` - View via API: `GET /api/logs` - Adjust log level: modify `logging.level` in `config/config.yaml` --- ## ✅ Code Review Checklist Before submitting code, ensure: - [ ] Code follows PEP 8 style guide - [ ] Running `uv run ruff check .` produces no errors - [ ] Running `uv run ruff format .` to format code - [ ] All functions and classes have type annotations - [ ] All public functions and classes have docstrings - [ ] Appropriate error handling has been added - [ ] Parameterized queries are used to prevent SQL injection - [ ] Necessary logging has been added - [ ] Relevant documentation has been updated - [ ] API changes are reflected in OpenAPI Schema ================================================ FILE: .cursor/commands/backend_CN.md ================================================ # 后端开发快捷命令(lifetrace 版) ## 技术栈信息 - **框架**: FastAPI + Uvicorn(异步 Web 框架) - **语言**: Python 3.12 - **ORM**: SQLAlchemy 2.x + SQLModel - **数据库迁移**: Alembic - **数据验证**: Pydantic 2.x - **配置管理**: Dynaconf(支持 YAML 热重载) - **日志**: Loguru - **调度器**: APScheduler(后台任务调度) - **OCR**: RapidOCR(本地 OCR 识别) - **向量数据库**: ChromaDB(可选,用于语义搜索) - **文本嵌入**: sentence-transformers(可选) - **LLM**: OpenAI 兼容 API - **包管理**: uv(推荐) - **代码质量**: Ruff(lint/format/check) --- ## 🏗️ 项目架构 ``` lifetrace/ ├── server.py # FastAPI 应用入口 ├── config/ # 配置文件目录 │ ├── config.yaml # 用户配置 │ ├── default_config.yaml # 默认配置 │ └── prompt.yaml # LLM Prompt 模板 ├── routers/ # API 路由层 ├── services/ # 业务服务层 ├── repositories/ # 数据访问层(Repository 模式) ├── schemas/ # Pydantic 数据模型 ├── storage/ # 数据存储层(SQLAlchemy 模型) ├── llm/ # LLM 和 AI 服务 ├── jobs/ # 后台任务 ├── core/ # 核心依赖和懒加载服务 └── util/ # 工具函数 ``` ### 分层架构说明 - **Router 层**:处理 HTTP 请求,参数验证,调用 Service 层 - **Service 层**:业务逻辑,编排多个 Repository 操作 - **Repository 层**:数据访问抽象,封装数据库查询 - **Schema 层**:请求/响应的 Pydantic 模型 - **Storage 层**:SQLAlchemy ORM 模型定义 --- ## 🔧 路由开发 ### 创建新的 API 路由 在 `lifetrace/routers/` 目录下创建新路由: - 使用 `APIRouter` 定义路由前缀和标签 - 遵循 RESTful API 设计规范 - 使用依赖注入获取数据库会话 - 添加完整的类型注解和文档字符串 ### RESTful 路由规范 - `GET /api/{resource}` - 获取列表 - `GET /api/{resource}/{id}` - 获取单个资源 - `POST /api/{resource}` - 创建资源 - `PUT /api/{resource}/{id}` - 全量更新 - `PATCH /api/{resource}/{id}` - 部分更新 - `DELETE /api/{resource}/{id}` - 删除资源 ### 注册路由 在 `server.py` 中导入并注册新路由: - 使用 `app.include_router(xxx.router)` 注册 - 路由按功能模块组织 --- ## 📦 数据模型 ### Pydantic Schema 规范 在 `lifetrace/schemas/` 目录下创建数据模型: - 使用 Pydantic v2 语法 - 区分 `Create`、`Update`、`Response` 等不同场景的模型 - 使用 `Field()` 添加验证规则和描述 - 启用 `model_config = ConfigDict(from_attributes=True)` 支持 ORM 转换 ### 常用模型模式 - `{Resource}Create` - 创建时的请求体 - `{Resource}Update` - 更新时的请求体(字段通常为 Optional) - `{Resource}Response` - API 响应格式 - `{Resource}List` - 列表响应(包含分页信息) ### SQLAlchemy 模型规范 在 `lifetrace/storage/models.py` 中定义数据库表: - 使用 SQLAlchemy 2.x 声明式语法 - 为常用查询字段添加索引 - 使用关系(relationship)定义表关联 - 添加 `created_at` 和 `updated_at` 时间戳字段 --- ## 🗄️ Repository 层 ### 创建 Repository 在 `lifetrace/repositories/` 目录下创建数据访问类: - 继承或实现 `interfaces.py` 中定义的接口 - 封装所有数据库查询逻辑 - 使用异步方法(`async def`) - 支持参数化查询,防止 SQL 注入 ### Repository 命名规范 - `sql_{resource}_repository.py` - SQL 数据库实现 - 类名使用 `{Resource}Repository` 格式 --- ## 🎯 Service 层 ### 创建 Service 在 `lifetrace/services/` 目录下创建业务服务: - 实现复杂的业务逻辑 - 编排多个 Repository 操作 - 处理事务边界 - 调用外部服务(LLM、OCR 等) ### Service 规范 - 类名使用 `{Resource}Service` 格式 - 通过依赖注入获取 Repository 实例 - 业务异常使用自定义 Exception 类 - 添加详细的日志记录 --- ## 🤖 LLM 服务 ### LLM 客户端使用 项目使用 OpenAI 兼容 API,通过 `llm/llm_client.py` 封装: - 支持阿里云通义千问、OpenAI、Claude 等 - 配置通过 `config/config.yaml` 的 `llm` 部分管理 - 支持流式响应(SSE) ### RAG 服务 `llm/rag_service.py` 提供检索增强生成: - 智能时间解析(如"上周"、"昨天") - 混合检索策略(向量检索 + 全文检索) - 上下文压缩和排序 ### Prompt 管理 Prompt 模板统一存放在 `config/prompt.yaml`: - 使用 YAML 格式便于维护 - 支持变量插值 - 按功能模块组织 ### Agno Agent `llm/agno_agent.py` 提供基于 [Agno 框架](https://docs.agno.com/) 的 AI 待办管理: - FreeTodoToolkit 包含 14 个工具(CRUD、任务拆解、时间解析等) - 国际化支持(中/英文) - 基于 Mixin 的可扩展架构 详细开发指南见 `.cursor/commands/agno_agent_CN.md`。 --- ## ⏰ 后台任务 ### 任务调度 使用 APScheduler 管理后台任务: - 任务定义在 `lifetrace/jobs/` 目录 - 通过 `job_manager.py` 统一管理 - 支持定时任务和间隔任务 ### 任务类型 - **recorder**: 屏幕录制器,定时截图 - **ocr**: OCR 处理器,处理待识别的截图 --- ## ⚙️ 配置管理 ### 配置文件结构 - `config/default_config.yaml` - 默认配置(不要修改) - `config/config.yaml` - 用户配置(覆盖默认值) - 使用 Dynaconf 支持配置热重载 ### 访问配置 通过 `util/settings.py` 中的 `settings` 对象访问: - `settings.server.port` - 访问嵌套配置 - `settings.get("key", default)` - 带默认值访问 ### 配置热重载 以下配置支持热重载(无需重启): - LLM 配置 - 录制配置 - OCR 配置 --- ## 📝 日志记录 ### 使用 Loguru 从 `util/logging_config.py` 导入 logger: - `logger.info()` - 普通信息 - `logger.warning()` - 警告信息 - `logger.error()` - 错误信息 - `logger.debug()` - 调试信息 ### 日志规范 - 关键操作必须记录日志 - 异常必须记录完整堆栈 - 敏感信息(API Key 等)必须脱敏 - 使用结构化日志便于分析 --- ## 🗃️ 数据库迁移 ### 使用 Alembic 项目使用 Alembic 管理数据库迁移: - 配置文件:`alembic.ini` - 迁移脚本:`migrations/versions/` ### 常用命令 - `alembic revision --autogenerate -m "描述"` - 生成迁移脚本 - `alembic upgrade head` - 应用所有迁移 - `alembic downgrade -1` - 回滚一个版本 - `alembic history` - 查看迁移历史 --- ## 🧪 代码质量 ### Ruff 检查和格式化 项目使用 Ruff 进行代码检查和格式化: - `uv run ruff check .` - 检查代码 - `uv run ruff check --fix .` - 自动修复问题 - `uv run ruff format .` - 格式化代码 ### 代码规范 - 遵循 PEP 8 风格指南 - 每行不超过 100 字符 - 单个文件不超过 500 行(警戒线 700 行) - 单个函数不超过 50 条语句 - 圈复杂度不超过 15 --- ## 🔐 错误处理 ### HTTP 异常 使用 FastAPI 的 `HTTPException`: - `400` - 请求参数错误 - `404` - 资源不存在 - `422` - 验证错误(Pydantic 自动处理) - `500` - 服务器内部错误 ### 异常处理规范 - 捕获特定异常,避免捕获所有异常 - 记录错误日志并包含上下文 - 返回用户友好的错误信息 - 敏感信息不要暴露给客户端 --- ## 🚀 性能优化 ### 数据库查询优化 - 使用 `selectinload` 避免 N+1 查询 - 为常用查询字段添加索引 - 使用分页限制返回数据量 - 批量操作代替循环单条操作 ### 异步处理 - 使用 `async/await` 处理 I/O 操作 - 数据库查询使用异步会话 - 外部 API 调用使用异步客户端 ### 懒加载 - 大型服务(向量服务、OCR)使用懒加载 - 通过 `core/lazy_services.py` 按需初始化 - 避免启动时加载所有依赖 --- ## 📡 API 与前端交互 ### 命名风格转换 后端使用 `snake_case`,前端使用 `camelCase`: - 前端 fetcher 自动进行转换 - 后端 Schema 统一使用 `snake_case` - OpenAPI Schema 由 FastAPI 自动生成 ### 前端代码生成 前端使用 Orval 根据 OpenAPI Schema 自动生成 API 代码: - 后端 API 变更后,前端运行 `pnpm orval` 重新生成 - 确保 OpenAPI Schema 完整且准确 --- ## 📋 依赖管理 ### 使用 uv 项目使用 uv 作为包管理器: - `uv sync` - 同步依赖 - `uv add ` - 添加依赖 - `uv remove ` - 移除依赖 - `uv run ` - 在虚拟环境中运行命令 ### 依赖分组 - 主依赖:`pyproject.toml` 的 `dependencies` - 开发依赖:`dependency-groups.dev` - 可选依赖:`dependency-groups.vector`(向量搜索功能) --- ## 🔍 调试和排查 ### 启动开发服务器 - `python -m lifetrace.server` - 直接启动 - `uvicorn lifetrace.server:app --reload` - 热重载模式 ### API 文档 - Swagger UI: `http://localhost:8001/docs` - ReDoc: `http://localhost:8001/redoc` - OpenAPI JSON: `http://localhost:8001/openapi.json` ### 日志查看 - 日志文件位于 `lifetrace/data/logs/` - 通过 API 查看:`GET /api/logs` - 调整日志级别:修改 `config/config.yaml` 的 `logging.level` --- ## ✅ 代码检查清单 在提交代码前,请确保: - [ ] 代码遵循 PEP 8 风格指南 - [ ] 运行 `uv run ruff check .` 没有错误 - [ ] 运行 `uv run ruff format .` 格式化代码 - [ ] 所有函数和类都有类型注解 - [ ] 所有公共函数和类都有文档字符串 - [ ] 添加了适当的错误处理 - [ ] 使用了参数化查询防止 SQL 注入 - [ ] 添加了必要的日志记录 - [ ] 更新了相关文档 - [ ] API 变更已在 OpenAPI Schema 中反映 ================================================ FILE: .cursor/commands/dynamic-island.md ================================================ # 灵动岛实现指南(Dynamic Island) ## 概述 灵动岛是一个悬浮 UI 组件,为 Electron 应用提供三种交互模式: - **FLOAT 模式**:小型悬浮岛,可拖拽,悬停时展开 - **PANEL 模式**:可调整大小的面板窗口,显示单个功能 - **MAXIMIZE 模式**:最大化工作台,显示完整的应用功能 --- ## 🚀 实现原理与技术栈 ### 核心技术 - **React 19 + TypeScript**:组件化开发,类型安全 - **Framer Motion**:流畅的动画和布局过渡 - **Electron IPC**:主进程与渲染进程通信 - **CSS 注入**:动态修改窗口样式(透明度、圆角等) - **窗口管理 API**:`setIgnoreMouseEvents`、`setAlwaysOnTop`、`setBounds` 等 ### 全局常驻 Overlay 设计(新实现) - 灵动岛现在作为一个**全局常驻 overlay 层**存在: - 最外层容器始终是 `position: fixed; inset: 0; pointer-events: none; z-index: 1000002`。 - 通过 `ref` 回调 + `requestAnimationFrame` 连续调用 `style.setProperty(..., 'important')`,确保上述属性不会被其他样式覆盖。 - 三种模式(FLOAT / PANEL / MAXIMIZE)只是改变「内容层」(PanelWindow / 最大化页面)的布局和 Electron 窗口策略: - 灵动岛的布局计算固定使用 `layoutMode = IslandMode.FLOAT`,保证拖拽位置和吸边逻辑在所有模式下统一。 - N 徽章等全局元素也应放在这一 overlay 层内,确保不会因为窗口变窄而被“挤进 Panel”。 ### Electron IPC 通信机制 **IPC(Inter-Process Communication)** 是 Electron 中主进程(Main Process)和渲染进程(Renderer Process)之间通信的桥梁。 **为什么需要 IPC?** - Electron 应用分为主进程和渲染进程,主进程负责窗口管理、系统 API 调用等,渲染进程负责 UI 渲染 - 出于安全考虑,渲染进程无法直接调用 Node.js API 和 Electron 窗口 API - 需要通过 IPC 让渲染进程请求主进程执行窗口操作 **在灵动岛中的使用**: - **渲染进程 → 主进程**:通过 `ipcRenderer.send()` 或 `ipcRenderer.invoke()` 发送请求 - `collapse-window`:请求折叠窗口到 FLOAT 模式 - `expand-window`:请求展开窗口到 PANEL 模式 - `expand-window-full`:请求展开窗口到 MAXIMIZE 模式 - `set-ignore-mouse-events`:请求设置点击穿透 - `move-window`:请求移动窗口位置 - **主进程处理**:在 `electron/ipc-handlers.ts` 中注册处理器,执行实际的窗口操作 - 调用 `BrowserWindow` API 修改窗口属性 - 通过 `webContents.insertCSS()` 注入样式 - 执行窗口动画过渡 **代码示例**: ```typescript // 渲染进程(前端) const api = getElectronAPI(); await api.electronAPI?.collapseWindow?.(); // 主进程(electron/ipc-handlers.ts) ipcMain.handle("collapse-window", async () => { const win = windowManager.getWindow(); // 执行窗口操作... }); ``` ### 实现总结 灵动岛的实现通过以下技术组合完成: 1. **通过 Electron IPC 通信**,让前端渲染进程请求主进程执行窗口操作(调整大小、位置、属性等) 2. **通过 CSS 注入**,动态修改窗口样式(透明度、圆角、裁剪路径),实现视觉效果的平滑过渡 3. **通过窗口动画**,使用缓动函数和定时器,以约 60fps 的频率更新窗口边界,实现平滑的尺寸变化 4. **通过点击穿透管理**,在 FLOAT 模式下启用 `setIgnoreMouseEvents`,让窗口不阻挡桌面操作,同时通过 `forward: true` 保持鼠标事件检测 5. **通过 Framer Motion**,在前端实现组件布局的平滑动画,配合窗口动画实现整体过渡效果 6. **通过状态管理**,使用 Zustand store 管理模式状态,使用 React Context 在组件间共享功能状态 7. **通过自定义 Hooks**,将拖拽、悬停检测、布局计算等逻辑封装,保持代码模块化和可维护性 这种架构实现了窗口级别的动画(主进程控制)和组件级别的动画(渲染进程控制)的协同工作,创造出流畅的模式切换体验。 ### 关键技术点 #### 1. 点击穿透(Click-Through) **实现方式(两层控制)**: - **渲染层 hook**:`components/dynamic-island/hooks/useDynamicIslandClickThrough.ts` - 负责灵动岛本身在 FLOAT 模式下,依据悬停/拖拽状态切换局部 `pointer-events`。 - **窗口层 hook**:`lib/hooks/useElectronClickThrough.ts` - 统一调用 Electron 的 `setIgnoreMouseEvents`,根据模式和鼠标位置控制整窗是否穿透。 **当前行为**: - **FLOAT 模式**: - 窗口层:`setIgnoreMouseEvents(true, { forward: true })`,整窗穿透但仍可接收 `mousemove`。 - 渲染层:灵动岛在 hover/drag 时打开局部 `pointer-events`,实现“悬浮但可交互”。 - **PANEL 模式**: - 进入 PANEL 时立即 `setIgnoreMouseEvents(false)`,确保一开始就能点击 PanelWindow。 - 监听全局 `mousemove`,根据 `[data-panel-window]` 的 `getBoundingClientRect()`: - 鼠标在 panel 内部(含顶部 8px 扩展区域)→ `setIgnoreMouseEvents(false)`。 - 鼠标在 panel 外部透明区域 → `setIgnoreMouseEvents(true, { forward: true })`。 - **MAXIMIZE 模式**: - 始终 `setIgnoreMouseEvents(false)`,整窗可交互。 #### 2. 窗口动画过渡 **实现方式**: - 使用 `easeOutCubic` 缓动函数实现平滑过渡 - 通过 `setBounds()` 以约 60fps 的频率更新窗口边界 - 动画期间通过 CSS 注入控制透明度,避免内容闪现 **代码位置**:`electron/ipc-handlers.ts` 的 `animateWindowBounds` 函数 ```typescript // 缓动函数:easeOutCubic function easeOutCubic(t: number): number { return 1 - (1 - t) ** 3; } // 动画循环:约 60fps setTimeout(animate, 16); ``` #### 3. 拖拽实现 **实现方式**: - 完全手动实现,不依赖 Electron 的 `setMovable` - 监听 `mousedown`、`mousemove`、`mouseup` 事件 - 实时更新 DOM 位置,拖拽结束后通过 Framer Motion 平滑移动到吸附位置 - 支持边缘吸附(50px 阈值) **代码位置**:`hooks/useDynamicIslandDrag.ts` **关键逻辑**: 1. `mousedown`:记录起始位置,禁用点击穿透 2. `mousemove`:计算新位置,限制在屏幕范围内 3. `mouseup`:计算吸附位置,通过 `setPosition` 触发 Framer Motion 动画 #### 4. 悬停检测 **实现方式**: - 全局 `mousemove` 事件监听 - 使用 `getBoundingClientRect()` 检测鼠标是否在区域内 - 使用 `requestAnimationFrame` 节流,优化性能 - 10px 容差避免边缘抖动 **代码位置**:`hooks/useDynamicIslandHover.ts` ```typescript // 节流处理 let rafId: number | null = null; const throttledHandleMouseMove = (e: MouseEvent) => { if (rafId) return; rafId = requestAnimationFrame(() => { handleGlobalMouseMove(e); rafId = null; }); }; ``` #### 5. 透明度与可见性恢复(配合全局 overlay) **问题**:从 PANEL/MAXIMIZE 折叠到 FLOAT 时,如果主进程仍保留 `opacity: 0` 等样式,灵动岛窗口可能出现“看不见但还在”的状态。 **解决方案(新实现)**: - 主进程在折叠/动画期间仍可以注入 `opacity: 0`,避免尺寸变化过程闪现内容。 - `DynamicIsland` 挂载与模式切换时,通过 `useEffect` 与 `ref` 回调: - 对 overlay 容器本身强制 `opacity: 1; visibility: visible`。 - 必要时通过 `
正在准备 FreeTodo
首次启动会自动安装 Python 3.12 与依赖。
准备中...
安装位置: -
Python 环境: -
虚拟环境: -
0%

			
`; } export function createBootstrapWindow(): BrowserWindow { if (bootstrapWindow) { return bootstrapWindow; } bootstrapWindow = new BrowserWindow({ width: 520, height: 400, resizable: false, show: false, title: "FreeTodo Setup", closable: true, alwaysOnTop: true, backgroundColor: "#f8f6f1", webPreferences: { nodeIntegration: true, contextIsolation: false, }, }); bootstrapWindow.setMenuBarVisibility(false); bootstrapWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(getBootstrapHtml())}`); bootstrapWindow.once("ready-to-show", () => { bootstrapWindow?.show(); }); bootstrapWindow.on("closed", () => { bootstrapWindow = null; }); if (!listenersAttached) { onStatus((status) => { if (!bootstrapWindow) { return; } bootstrapWindow.webContents.send("bootstrap:status", status); }); onLog((line) => { if (!bootstrapWindow) { return; } bootstrapWindow.webContents.send("bootstrap:log", line); }); onComplete(() => { if (!bootstrapWindow) { return; } bootstrapWindow.webContents.send("bootstrap:complete"); }); listenersAttached = true; } return bootstrapWindow; } export function getBootstrapWindow(): BrowserWindow | null { return bootstrapWindow; } export function closeBootstrapWindow(): void { if (!bootstrapWindow) { return; } bootstrapWindow.close(); bootstrapWindow = null; } ================================================ FILE: free-todo-frontend/electron/config.ts ================================================ /** * Electron 主进程配置常量 * 集中管理所有配置项,消除魔法数字 */ import { app } from "electron"; /** * 服务器模式类型 * - dev: 开发模式(从源码运行或 pnpm dev) * - build: 打包模式(Electron 打包后运行) */ export type ServerMode = "dev" | "build"; /** * 获取当前服务器模式 * 打包后的应用为 "build" 模式,开发时为 "dev" 模式 */ export function getServerMode(): ServerMode { // 如果 app.isPackaged 为 true,说明是打包后的应用 // 注意:此函数必须在 app ready 之后调用才能获得正确的 isPackaged 值 // 但 PORT_CONFIG 是在模块加载时就需要的,所以我们使用环境变量来判断 // 在开发模式下 NODE_ENV 通常不是 "production" 或者 app.isPackaged 为 false // 首先检查显式设置的环境变量 if (process.env.SERVER_MODE === "build") { return "build"; } if (process.env.SERVER_MODE === "dev") { return "dev"; } // 尝试使用 app.isPackaged(如果 app 已经初始化) try { return app.isPackaged ? "build" : "dev"; } catch { // app 未初始化,使用 NODE_ENV 判断 return process.env.NODE_ENV === "production" ? "build" : "dev"; } } /** * 端口范围配置 * DEV 模式和 Build 模式使用不同的端口范围,避免冲突 */ const PORT_RANGES = { /** DEV 模式端口范围 */ dev: { frontend: 3001, // DEV 前端从 3001 开始 backend: 8001, // DEV 后端从 8001 开始 }, /** Build 模式端口范围 */ build: { frontend: 3100, // Build 前端从 3100 开始 backend: 8100, // Build 后端从 8100 开始 }, } as const; /** * 端口配置 * 根据服务器模式动态选择端口范围 */ export const PORT_CONFIG = { /** 前端服务器端口配置 */ frontend: { /** 默认端口(可通过 PORT 环境变量覆盖) */ get default(): number { if (process.env.PORT) { return Number.parseInt(process.env.PORT, 10); } const mode = getServerMode(); return PORT_RANGES[mode].frontend; }, /** 端口探测最大尝试次数 */ maxAttempts: 50, }, /** 后端服务器端口配置 */ backend: { /** 默认端口(可通过 BACKEND_PORT 环境变量覆盖) */ get default(): number { if (process.env.BACKEND_PORT) { return Number.parseInt(process.env.BACKEND_PORT, 10); } const mode = getServerMode(); return PORT_RANGES[mode].backend; }, /** 端口探测最大尝试次数 */ maxAttempts: 50, }, } as const; /** * 超时配置(毫秒) */ export const TIMEOUT_CONFIG = { /** 等待后端服务器就绪的超时时间(3 分钟) */ backendReady: 180_000, /** 等待前端服务器就绪的超时时间(30 秒) */ frontendReady: 30_000, /** 单次健康检查的超时时间(5 秒) */ healthCheck: 5_000, /** 健康检查重试间隔(500 毫秒) */ healthCheckRetry: 500, /** 应用退出延迟(让用户看到错误消息,3 秒) */ quitDelay: 3_000, } as const; /** * 健康检查间隔配置(毫秒) */ export const HEALTH_CHECK_INTERVAL = { /** 前端服务器健康检查间隔(10 秒) */ frontend: 10_000, /** 后端服务器健康检查间隔(30 秒) */ backend: 30_000, } as const; /** * 窗口配置 */ export const WINDOW_CONFIG = { /** 初始宽度 */ width: 1200, /** 初始高度 */ height: 800, /** 最小宽度 */ minWidth: 800, /** 最小高度 */ minHeight: 600, /** 背景颜色(深色主题) */ backgroundColor: "#1a1a1a", } as const; /** * 窗口模式类型 * - island: 灵动岛模式(默认,透明悬浮窗) * - web: Web 界面模式(普通窗口,类似浏览器) */ export type WindowMode = "island" | "web"; /** * 编译时注入的默认窗口模式 * 由 esbuild 在构建时通过 define 选项设置 * 如果未定义,默认为 "web" */ declare const __DEFAULT_WINDOW_MODE__: string | undefined; /** * 后端运行时类型 * - script: 使用系统 Python + venv * - pyinstaller: 使用 PyInstaller 打包的可执行文件 */ export type BackendRuntime = "script" | "pyinstaller"; /** * 编译时注入的默认后端运行时 */ declare const __DEFAULT_BACKEND_RUNTIME__: string | undefined; /** * 获取当前窗口模式 * * 优先级: * 1. 运行时环境变量 WINDOW_MODE(方便调试) * 2. 编译时注入的默认值 __DEFAULT_WINDOW_MODE__ * 3. 硬编码默认值 "web" */ export function getWindowMode(): WindowMode { // 运行时环境变量优先(方便调试和开发) const envMode = process.env.WINDOW_MODE?.toLowerCase(); if (envMode === "web" || envMode === "island") { return envMode; } // 编译时注入的默认值 try { const buildTimeDefault = typeof __DEFAULT_WINDOW_MODE__ !== "undefined" ? __DEFAULT_WINDOW_MODE__ : undefined; if (buildTimeDefault === "web") { return "web"; } } catch { // __DEFAULT_WINDOW_MODE__ 未定义,使用硬编码默认值 } // 硬编码默认值 return "web"; } /** * 获取后端运行时类型 * * 优先级: * 1. 运行时环境变量 FREETODO_BACKEND_RUNTIME * 2. 编译时注入的默认值 __DEFAULT_BACKEND_RUNTIME__ * 3. 硬编码默认值 "script" */ export function getBackendRuntime(): BackendRuntime { const envRuntime = process.env.FREETODO_BACKEND_RUNTIME?.toLowerCase(); if (envRuntime === "script" || envRuntime === "pyinstaller") { return envRuntime; } try { const buildTimeDefault = typeof __DEFAULT_BACKEND_RUNTIME__ !== "undefined" ? __DEFAULT_BACKEND_RUNTIME__ : undefined; if (buildTimeDefault === "pyinstaller") { return "pyinstaller"; } } catch { // ignore } return "script"; } /** * 日志配置 */ export const LOG_CONFIG = { /** 日志缓冲区显示的最大字符数 */ bufferDisplayLimit: 2000, /** 错误对话框中显示的日志最大字符数 */ dialogDisplayLimit: 1000, } as const; /** * 进程配置 */ export const PROCESS_CONFIG = { /** 后端入口脚本(相对 backend 根目录) */ backendEntryScript: "lifetrace/scripts/start_backend.py", /** 后端可执行文件名称 */ backendExecutable: process.platform === "win32" ? "lifetrace.exe" : "lifetrace", /** 后端依赖清单(相对 backend 根目录) */ backendRequirementsFile: "requirements-runtime.txt", /** 后端运行时目录名(应用安装目录下) */ backendRuntimeDir: "runtime", /** 后端虚拟环境目录名(运行时目录下) */ backendVenvDir: "python-venv", /** 后端数据目录名 */ backendDataDir: "lifetrace-data", } as const; /** * 判断当前是否为开发模式 * 打包的应用始终为生产模式 */ export function isDevelopment(isPackaged: boolean): boolean { return !isPackaged && process.env.NODE_ENV !== "production"; } ================================================ FILE: free-todo-frontend/electron/git-info.ts ================================================ import { execSync } from "node:child_process"; import path from "node:path"; let cachedCommit: string | null = null; export function getGitCommit(): string | null { const envCommit = process.env.FREETODO_GIT_COMMIT || process.env.GIT_COMMIT; if (envCommit) { return envCommit; } if (cachedCommit !== null) { return cachedCommit; } const repoRoot = path.resolve(__dirname, ".."); if (repoRoot.includes(".asar")) { cachedCommit = null; return cachedCommit; } try { const commit = execSync("git rev-parse HEAD", { cwd: repoRoot, stdio: ["ignore", "pipe", "ignore"], }) .toString() .trim(); cachedCommit = commit || null; } catch { cachedCommit = null; } return cachedCommit; } ================================================ FILE: free-todo-frontend/electron/global-shortcut-manager.ts ================================================ /** * Global Keyboard Shortcuts Manager * Centralized management for all global keyboard shortcuts * Supports user-customizable shortcuts (future enhancement) */ import { app, globalShortcut } from "electron"; import type { IslandWindowManager } from "./island-window-manager"; import { logger } from "./logger"; /** * Shortcut configuration interface */ interface ShortcutConfig { /** Keyboard accelerator string (e.g., "CommandOrControl+Shift+I") */ accelerator: string; /** Human-readable description */ description: string; /** Handler function */ handler: () => void; } /** * GlobalShortcutManager class * Manages all global keyboard shortcuts for the application */ export class GlobalShortcutManager { /** Island window manager reference */ private islandWindowManager: IslandWindowManager; /** Map of registered shortcuts: name -> config */ private shortcuts: Map = new Map(); /** Track which shortcuts are successfully registered */ private registeredAccelerators: Set = new Set(); /** * Default shortcut configurations * Can be overridden by user preferences in the future */ private readonly defaultShortcuts = { toggleIsland: { accelerator: "CommandOrControl+Shift+I", description: "Toggle Island window visibility", }, // Future shortcuts can be added here: // startRecording: { // accelerator: "CommandOrControl+Shift+R", // description: "Start/stop recording", // }, // takeScreenshot: { // accelerator: "CommandOrControl+Shift+S", // description: "Take screenshot", // }, }; /** * Constructor * @param islandWindowManager Island window manager instance */ constructor(islandWindowManager: IslandWindowManager) { this.islandWindowManager = islandWindowManager; this.setupCleanup(); } /** * Register all default shortcuts */ registerDefaults(): void { logger.info("Registering default global shortcuts..."); // Register toggle island shortcut this.register( "toggleIsland", this.defaultShortcuts.toggleIsland.accelerator, this.defaultShortcuts.toggleIsland.description, () => { this.islandWindowManager.toggle(); logger.info("Island toggled via global shortcut"); }, ); // Future: register additional shortcuts here // Log registration summary logger.info( `Global shortcuts registered: ${this.registeredAccelerators.size}/${this.shortcuts.size}`, ); } /** * Register a global shortcut * @param name Unique name for the shortcut * @param accelerator Keyboard accelerator (e.g., "Ctrl+Shift+X") * @param description Human-readable description * @param handler Function to execute when shortcut is triggered * @returns true if registered successfully, false otherwise */ register( name: string, accelerator: string, description: string, handler: () => void, ): boolean { // Store the shortcut configuration const config: ShortcutConfig = { accelerator, description, handler, }; this.shortcuts.set(name, config); // Attempt to register with Electron try { const registered = globalShortcut.register(accelerator, () => { logger.info(`Global shortcut triggered: ${name} (${accelerator})`); handler(); }); if (registered) { this.registeredAccelerators.add(accelerator); logger.info(`Global shortcut registered: ${name} (${accelerator}) - ${description}`); return true; } logger.warn( `Failed to register global shortcut: ${name} (${accelerator}) - may be in use by another application`, ); return false; } catch (error) { logger.error( `Error registering global shortcut ${name}: ${error instanceof Error ? error.message : String(error)}`, ); return false; } } /** * Unregister a specific shortcut * @param name Name of the shortcut to unregister */ unregister(name: string): void { const config = this.shortcuts.get(name); if (!config) { logger.warn(`Shortcut not found: ${name}`); return; } try { globalShortcut.unregister(config.accelerator); this.registeredAccelerators.delete(config.accelerator); this.shortcuts.delete(name); logger.info(`Global shortcut unregistered: ${name} (${config.accelerator})`); } catch (error) { logger.error( `Error unregistering global shortcut ${name}: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Unregister all shortcuts */ unregisterAll(): void { try { globalShortcut.unregisterAll(); this.registeredAccelerators.clear(); this.shortcuts.clear(); logger.info("All global shortcuts unregistered"); } catch (error) { logger.error( `Error unregistering all shortcuts: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Check if a specific accelerator is registered * @param accelerator Keyboard accelerator to check * @returns true if registered, false otherwise */ isRegistered(accelerator: string): boolean { return globalShortcut.isRegistered(accelerator); } /** * Get all registered shortcuts * @returns Map of shortcut name to configuration */ getShortcuts(): Map { return new Map(this.shortcuts); } /** * Update a shortcut's accelerator (future feature) * Useful for user-customizable shortcuts * @param name Name of the shortcut * @param newAccelerator New keyboard accelerator * @returns true if updated successfully, false otherwise */ updateShortcut(name: string, newAccelerator: string): boolean { const config = this.shortcuts.get(name); if (!config) { logger.warn(`Shortcut not found: ${name}`); return false; } // Unregister old shortcut try { globalShortcut.unregister(config.accelerator); this.registeredAccelerators.delete(config.accelerator); } catch (error) { logger.error( `Error unregistering old shortcut: ${error instanceof Error ? error.message : String(error)}`, ); } // Register with new accelerator const registered = this.register( name, newAccelerator, config.description, config.handler, ); if (registered) { logger.info(`Shortcut ${name} updated to ${newAccelerator}`); } else { // Rollback: re-register with old accelerator logger.warn(`Failed to update shortcut ${name}, rolling back to ${config.accelerator}`); this.register(name, config.accelerator, config.description, config.handler); } return registered; } /** * Setup cleanup handlers to unregister shortcuts on app quit */ private setupCleanup(): void { // Unregister all shortcuts before app quits app.on("will-quit", () => { logger.info("App quitting, cleaning up global shortcuts..."); this.unregisterAll(); }); // Also clean up on process termination signals const cleanup = () => { this.unregisterAll(); }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); } /** * Future: Load custom shortcuts from user preferences * This would read from a config file or electron-store */ // loadCustomShortcuts(): void { // // TODO: Implement loading from persistent storage // logger.info("Loading custom shortcuts from preferences..."); // } /** * Future: Save custom shortcuts to user preferences * This would write to a config file or electron-store */ // saveCustomShortcuts(): void { // // TODO: Implement saving to persistent storage // logger.info("Saving custom shortcuts to preferences..."); // } } ================================================ FILE: free-todo-frontend/electron/ipc-handlers-todo-capture.ts ================================================ /** * 待办提取相关的 IPC 处理器 * 从 ipc-handlers.ts 中提取,以保持文件大小在限制内 */ import { desktopCapturer, ipcMain, net, screen } from "electron"; import { Jimp } from "jimp"; import { logger } from "./logger"; import type { WindowManager } from "./window-manager"; type JimpScanContext = { bitmap: { data: Buffer; }; }; /** * 发送截图到后端进行待办提取 */ async function sendToBackend( apiUrl: string, imageBase64: string, createTodos: boolean = true, ): Promise<{ success: boolean; message: string; extractedTodos: Array<{ title: string; description?: string; time_info?: Record; source_text?: string; confidence: number; }>; createdCount: number; }> { return new Promise((resolve, reject) => { const postData = JSON.stringify({ image_base64: imageBase64, create_todos: createTodos, // 自动创建 draft 状态的待办 }); const request = net.request({ method: "POST", url: apiUrl, }); request.setHeader("Content-Type", "application/json"); let responseData = ""; request.on("response", (response) => { response.on("data", (chunk) => { responseData += chunk.toString(); }); response.on("end", () => { try { const result = JSON.parse(responseData); resolve({ success: result.success ?? false, message: result.message ?? "未知响应", extractedTodos: result.extracted_todos?.map( (todo: { title: string; description?: string; time_info?: Record; source_text?: string; confidence: number; }) => ({ title: todo.title, description: todo.description, time_info: todo.time_info, source_text: todo.source_text, confidence: todo.confidence ?? 0.5, }), ) ?? [], createdCount: result.created_count ?? 0, }); } catch (error) { reject(new Error(`解析响应失败: ${error}`)); } }); response.on("error", (error) => { reject(error); }); }); request.on("error", (error) => { reject(error); }); request.write(postData); request.end(); }); } /** * 在截图上绘制遮罩,遮住面板区域 * @param imageBuffer 图片的 Buffer * @param windowBounds 窗口位置和尺寸(屏幕坐标) * @param screenBounds 屏幕位置和尺寸 * @returns 处理后的图片 Buffer */ async function maskWindowArea( imageBuffer: Buffer, windowBounds: { x: number; y: number; width: number; height: number }, screenBounds: { x: number; y: number; width: number; height: number }, ): Promise { try { // 使用 Jimp 加载图片 const image = await Jimp.read(imageBuffer); const imageWidth = image.width; const imageHeight = image.height; // 计算窗口相对于屏幕的位置 // 截图是屏幕的截图,窗口位置是相对于主显示器的 const relativeX = windowBounds.x - screenBounds.x; const relativeY = windowBounds.y - screenBounds.y; // 计算缩放比例(截图可能被缩放了) const scaleX = imageWidth / screenBounds.width; const scaleY = imageHeight / screenBounds.height; // 将窗口坐标缩放以匹配截图尺寸 const scaledX = relativeX * scaleX; const scaledY = relativeY * scaleY; const scaledWidth = windowBounds.width * scaleX; const scaledHeight = windowBounds.height * scaleY; // 确保遮罩区域在图片范围内 const maskX = Math.max(0, Math.min(Math.round(scaledX), imageWidth)); const maskY = Math.max(0, Math.min(Math.round(scaledY), imageHeight)); const maskWidth = Math.min( Math.round(scaledWidth), imageWidth - maskX, ); const maskHeight = Math.min( Math.round(scaledHeight), imageHeight - maskY, ); // 如果窗口不在截图范围内,直接返回原图 if (maskWidth <= 0 || maskHeight <= 0) { logger.warn( "Window is outside screenshot bounds, skipping mask", ); return imageBuffer; } // 创建遮罩:使用半透明黑色矩形(90% 不透明度) // 使用 Jimp 的 scan 方法直接操作像素 image.scan( maskX, maskY, maskWidth, maskHeight, function (this: JimpScanContext, _x: number, _y: number, idx: number) { // 获取当前像素的颜色(RGBA 格式) // Jimp 的 bitmap.data 是 RGBA 格式:R, G, B, A const r = this.bitmap.data[idx] || 0; const g = this.bitmap.data[idx + 1] || 0; const b = this.bitmap.data[idx + 2] || 0; const a = this.bitmap.data[idx + 3] || 255; // 混合黑色遮罩(90% 不透明度) // 使用简单的 alpha 混合:result = source * (1 - alpha) + mask * alpha const alpha = 0.9; const newR = Math.round(r * (1 - alpha) + 0 * alpha); const newG = Math.round(g * (1 - alpha) + 0 * alpha); const newB = Math.round(b * (1 - alpha) + 0 * alpha); // 设置新颜色(保持原始 alpha 通道) this.bitmap.data[idx] = newR; this.bitmap.data[idx + 1] = newG; this.bitmap.data[idx + 2] = newB; // alpha 通道保持不变 this.bitmap.data[idx + 3] = a; }, ); // 返回处理后的图片 Buffer return await image.getBuffer("image/png"); } catch (error) { logger.error( `Failed to mask window area: ${error instanceof Error ? error.message : String(error)}`, ); // 如果遮罩失败,返回原图 return imageBuffer; } } /** * 设置待办提取相关的 IPC 处理器 */ export function setupTodoCaptureIpcHandlers( windowManager: WindowManager, ): void { // 截图并提取待办 ipcMain.handle( "capture-and-extract-todos", async ( _event, panelBounds?: { x: number; y: number; width: number; height: number } | null, ): Promise<{ success: boolean; message: string; extractedTodos: Array<{ title: string; description?: string; time_info?: Record; source_text?: string; confidence: number; }>; createdCount: number; }> => { try { logger.info("Capturing screen for todo extraction..."); // 不再隐藏窗口,直接截图 const mainWin = windowManager.getWindow(); if (!mainWin) { throw new Error("主窗口不存在"); } // 获取窗口位置和尺寸(屏幕坐标) const windowBounds = mainWin.getBounds(); // 获取主显示器的信息 const primaryDisplay = screen.getPrimaryDisplay(); const screenBounds = primaryDisplay.bounds; const displaySize = primaryDisplay.size; // 计算 panel 在屏幕上的绝对位置 // panelBounds 是相对于视口的位置,需要转换为屏幕坐标 let targetBounds: { x: number; y: number; width: number; height: number } | null = null; if (panelBounds) { // panelBounds 已经是相对于视口的位置(通过 getBoundingClientRect 获取) // 需要加上窗口在屏幕上的位置,转换为屏幕坐标 targetBounds = { x: windowBounds.x + panelBounds.x, y: windowBounds.y + panelBounds.y, width: panelBounds.width, height: panelBounds.height, }; } // 获取所有屏幕源(使用实际屏幕尺寸) const sources = await desktopCapturer.getSources({ types: ["screen"], thumbnailSize: { width: displaySize.width, height: displaySize.height, }, }); if (sources.length === 0) { logger.error("No screen sources found"); return { success: false, message: "未找到屏幕源", extractedTodos: [], createdCount: 0, }; } // 使用主屏幕的截图 const primarySource = sources[0]; const thumbnail = primarySource.thumbnail; // 将 nativeImage 转换为 Buffer const pngBuffer = thumbnail.toPNG(); // 在截图上绘制遮罩,遮住面板区域(只遮罩 panel,不是整个窗口) // 如果 targetBounds 为 null,不遮罩(直接使用原图) const maskedBuffer = targetBounds ? await maskWindowArea( pngBuffer, targetBounds, screenBounds, ) : pngBuffer; // 将处理后的图片转换为 base64 const base64Data = maskedBuffer.toString("base64"); // 获取后端 URL(从 next-server 模块) const nextServerModule = await import("./next-server"); const backendUrl = nextServerModule.getBackendUrl(); if (!backendUrl) { throw new Error("后端 URL 未设置,请等待后端服务器启动"); } const apiUrl = `${backendUrl}/api/floating-capture/extract-todos`; // 发送到后端(自动创建 draft 状态的待办) const response = await sendToBackend(apiUrl, base64Data, true); logger.info( `Todo extraction completed: ${response.extractedTodos.length} todos extracted`, ); return response; } catch (error) { const errorMsg = `Failed to capture and extract todos: ${error instanceof Error ? error.message : String(error)}`; logger.error(errorMsg); return { success: false, message: errorMsg, extractedTodos: [], createdCount: 0, }; } }, ); } ================================================ FILE: free-todo-frontend/electron/ipc-handlers.ts ================================================ /** * IPC 通信处理器 * 集中管理所有主进程与渲染进程之间的 IPC 通信 */ import { app, BrowserWindow, ipcMain, screen } from "electron"; import { setupTodoCaptureIpcHandlers } from "./ipc-handlers-todo-capture"; import type { IslandWindowManager } from "./island-window-manager"; import { logger } from "./logger"; import { type NotificationData, showSystemNotification, } from "./notification"; import type { WindowManager } from "./window-manager"; /** * 设置所有 IPC 处理器 * @param windowManager 窗口管理器实例 * @param islandWindowManager Island 窗口管理器实例(可选) */ export function setupIpcHandlers( windowManager: WindowManager, islandWindowManager?: IslandWindowManager, ): void { // 处理来自渲染进程的通知请求 ipcMain.handle( "show-notification", async (_event, data: NotificationData) => { try { logger.info(`Received notification request: ${data.id} - ${data.title}`); showSystemNotification(data, windowManager); } catch (error) { const errorMsg = `Failed to handle notification request: ${error instanceof Error ? error.message : String(error)}`; logger.error(errorMsg); throw error; } }, ); // ========== 窗口管理 IPC 处理器 ========== // 设置窗口是否忽略鼠标事件(用于透明窗口点击穿透) ipcMain.on( "set-ignore-mouse-events", (event, ignore: boolean, options?: { forward?: boolean }) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.setIgnoreMouseEvents(ignore, options || {}); } }, ); // 移动窗口到指定位置(用于拖拽) ipcMain.on("move-window", (event, x: number, y: number) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.setPosition(Math.round(x), Math.round(y)); } }); // 获取窗口当前位置 ipcMain.handle("get-window-position", () => { const win = windowManager.getWindow(); if (win) { const [x, y] = win.getPosition(); return { x, y }; } return { x: 0, y: 0 }; }); // 获取屏幕信息 ipcMain.handle("get-screen-info", () => { const { width, height } = screen.getPrimaryDisplay().workAreaSize; return { screenWidth: width, screenHeight: height }; }); // 退出应用 ipcMain.on("app-quit", () => { app.quit(); }); // 透明背景就绪通知 ipcMain.on("transparent-background-ready", () => { const win = windowManager.getWindow(); if (win) { win.setBackgroundColor("#00000000"); } }); // 设置窗口背景色 ipcMain.on("set-window-background-color", (event, color: string) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.setBackgroundColor(color); logger.info(`Window background color set to: ${color}`); } }); // ========== 待办提取相关 IPC 处理器 ========== // 已提取到 ipc-handlers-todo-capture.ts 以保持文件大小 setupTodoCaptureIpcHandlers(windowManager); // ========== Island 动态岛相关 IPC 处理器 ========== if (islandWindowManager) { setupIslandIpcHandlers(islandWindowManager); } } /** * 设置 Island 相关的 IPC 处理器 * @param islandWindowManager Island 窗口管理器实例 */ function setupIslandIpcHandlers(islandWindowManager: IslandWindowManager): void { // 显示 Island 窗口 ipcMain.on("island:show", () => { islandWindowManager.show(); logger.info("Island window shown via IPC"); }); // 隐藏 Island 窗口 ipcMain.on("island:hide", () => { islandWindowManager.hide(); logger.info("Island window hidden via IPC"); }); // 切换 Island 窗口显示/隐藏 ipcMain.on("island:toggle", () => { islandWindowManager.toggle(); logger.info("Island window toggled via IPC"); }); // 调整 Island 窗口大小(切换模式) // 注意:island:resize-window 在 island-window-manager.ts 中已处理 } ================================================ FILE: free-todo-frontend/electron/island-window-manager.ts ================================================ /** * Island 窗口管理器 * 负责创建和管理 Dynamic Island 悬浮窗口 */ import path from "node:path"; import { app, BrowserWindow, ipcMain, screen } from "electron"; import { logger } from "./logger"; /** * Island 模式枚举(与前端保持一致) */ export enum IslandMode { FLOAT = "FLOAT", POPUP = "POPUP", SIDEBAR = "SIDEBAR", FULLSCREEN = "FULLSCREEN", } /** * 各模式对应的窗口尺寸 */ const ISLAND_SIZES: Record = { [IslandMode.FLOAT]: { width: 200, height: 56 }, [IslandMode.POPUP]: { width: 380, height: 120 }, [IslandMode.SIDEBAR]: { width: 420, height: 700 }, [IslandMode.FULLSCREEN]: { width: 0, height: 0 }, // 动态计算 }; /** * Island 窗口管理器类 */ export class IslandWindowManager { /** Island 窗口实例 */ private islandWindow: BrowserWindow | null = null; /** 当前模式 */ private currentMode: IslandMode = IslandMode.FLOAT; /** 是否启用 Island */ private enabled: boolean = false; /** 窗口位置配置 */ private readonly marginRight: number = 20; private readonly marginTop: number = 20; /** 当前 Y 位置(用于垂直拖动时保持位置) */ private currentY: number = 20; /** SIDEBAR 模式的固定状态(默认为 true)*/ private sidebarPinned: boolean = true; /** 可见性变化回调 */ private onVisibilityChange?: (visible: boolean) => void; /** * 获取 preload 脚本路径 */ private getPreloadPath(): string { if (app.isPackaged) { return path.join(app.getAppPath(), "dist-electron", "preload.js"); } return path.join(__dirname, "preload.js"); } /** * 计算窗口 X 位置(右边缘对齐) * 所有非全屏模式共享相同的 X 位置,以便平滑过渡 */ private calculateRightAlignedX(width: number): number { const { width: screenWidth } = screen.getPrimaryDisplay().workAreaSize; return screenWidth - width - this.marginRight; } /** * 计算窗口 Y 位置 * 如果 preferredY 未提供,则使用保存的位置;否则约束在屏幕边界内 */ private calculateYPosition(height: number, preferredY?: number): number { const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize; if (preferredY !== undefined) { // 约束在屏幕边界内 const minY = this.marginTop; const maxY = screenHeight - height - this.marginTop; return Math.max(minY, Math.min(preferredY, maxY)); } // 使用已保存的位置 return this.currentY; } /** * 智能计算 SIDEBAR 的 Y 位置 * 根据当前窗口位置和可用空间,选择最佳的锚点(顶部或底部对齐) * @param sidebarHeight SIDEBAR 窗口的高度 * @returns 计算出的 Y 位置和使用的锚点类型 */ private calculateSmartSidebarPosition( sidebarHeight: number ): { y: number; anchor: 'top' | 'bottom' } { const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize; const currentWindowY = this.currentY; const currentWindowHeight = this.islandWindow?.getBounds().height || 56; // 计算当前窗口的底部位置 const currentWindowBottom = currentWindowY + currentWindowHeight; // 计算如果使用底部对齐,SIDEBAR 的顶部位置 const bottomAlignedY = currentWindowBottom - sidebarHeight; // 计算如果使用顶部对齐,SIDEBAR 的顶部位置 const topAlignedY = currentWindowY; // 检查底部对齐是否可行(SIDEBAR 完全在屏幕内) const canBottomAlign = bottomAlignedY >= this.marginTop; // 检查顶部对齐是否可行 const canTopAlign = (topAlignedY + sidebarHeight) <= (screenHeight - this.marginTop); // 决策逻辑: // 1. 如果当前窗口在屏幕上半部分,优先使用顶部对齐 // 2. 如果当前窗口在屏幕下半部分,优先使用底部对齐 // 3. 如果首选方案不可行,尝试另一种 // 4. 如果两种都不可行,居中显示并调整位置 const isInUpperHalf = currentWindowY < screenHeight / 2; if (isInUpperHalf) { // 上半部分:优先顶部对齐 if (canTopAlign) { return { y: topAlignedY, anchor: 'top' }; } else if (canBottomAlign) { return { y: bottomAlignedY, anchor: 'bottom' }; } } else { // 下半部分:优先底部对齐 if (canBottomAlign) { return { y: bottomAlignedY, anchor: 'bottom' }; } else if (canTopAlign) { return { y: topAlignedY, anchor: 'top' }; } } // 如果两种对齐都不可行,计算一个安全的居中位置 const safeY = Math.max( this.marginTop, Math.min( screenHeight - sidebarHeight - this.marginTop, currentWindowY ) ); logger.warn(`SIDEBAR doesn't fit with current anchor, adjusted to Y=${safeY}`); return { y: safeY, anchor: isInUpperHalf ? 'top' : 'bottom' }; } /** * 获取指定模式的窗口尺寸 */ private getSizeForMode(mode: IslandMode): { width: number; height: number } { if (mode === IslandMode.FULLSCREEN) { return screen.getPrimaryDisplay().workAreaSize; } return ISLAND_SIZES[mode]; } /** * 创建 Island 窗口 * @param serverUrl 前端服务器 URL */ create(serverUrl: string): void { if (this.islandWindow) { logger.warn("Island window already exists"); return; } const preloadPath = this.getPreloadPath(); const { width, height } = this.getSizeForMode(this.currentMode); const x = this.calculateRightAlignedX(width); const y = this.calculateYPosition(height); this.currentY = y; // 初始化 Y 位置 this.islandWindow = new BrowserWindow({ width, height, x, y, frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, resizable: false, movable: false, // 禁用原生拖动,使用自定义拖动 hasShadow: false, // 禁用系统阴影以避免透明窗口出现黑边,使用 CSS box-shadow 代替 focusable: true, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: preloadPath, }, show: false, backgroundColor: "#00000000", }); // 设置窗口级别,使其始终在最上层(包括全屏应用之上) this.islandWindow.setAlwaysOnTop(true, "floating"); // macOS 特定:设置窗口在所有工作区可见 if (process.platform === "darwin") { this.islandWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } // 加载 Island 页面 const islandUrl = `${serverUrl}/island`; this.islandWindow.loadURL(islandUrl); // 窗口准备好后显示 this.islandWindow.once("ready-to-show", () => { this.islandWindow?.show(); logger.info("Island window ready and shown"); }); // 窗口关闭时清理引用 this.islandWindow.on("closed", () => { this.islandWindow = null; logger.info("Island window closed"); // Island 是主窗口,关闭时退出应用(macOS 除外) if (process.platform !== "darwin") { app.quit(); } }); // 设置 IPC 处理器 this.setupIpcHandlers(); // 设置自定义拖拽处理器 this.setupCustomDragHandlers(); this.enabled = true; logger.info(`Island window created at ${islandUrl}`); } /** * 设置 Island 专用的 IPC 处理器 */ private setupIpcHandlers(): void { // 处理窗口大小调整请求 ipcMain.on("island:resize-window", (_event, mode: string) => { this.resizeToMode(mode as IslandMode); }); // 处理 SIDEBAR 模式多栏展开/收起请求 ipcMain.on("island:resize-sidebar", (_event, columnCount: number) => { this.resizeSidebarToColumns(columnCount as 1 | 2 | 3); }); // 处理 SIDEBAR 模式固定状态变化 ipcMain.on("island:set-pinned", (event, isPinned: boolean) => { // 只处理来自 Island 窗口的请求 if (this.islandWindow && event.sender === this.islandWindow.webContents) { this.setSidebarPinned(isPinned); } }); // 兼容旧的 resize-window 通道(来自原始 Island 代码) ipcMain.on("resize-window", (event, mode: string) => { // 只处理来自 Island 窗口的请求 if (this.islandWindow && event.sender === this.islandWindow.webContents) { this.resizeToMode(mode as IslandMode); } }); } /** * 设置自定义拖拽处理器(仅允许垂直拖动) */ private setupCustomDragHandlers(): void { // 存储拖拽起始位置 let dragStartY = 0; let windowStartY = 0; // 处理拖拽开始 ipcMain.on("island:drag-start", (event, mouseY: number) => { // 只处理来自 Island 窗口的请求 if (!this.islandWindow || event.sender !== this.islandWindow.webContents) return; // 全屏模式不允许拖拽 if (this.currentMode === IslandMode.FULLSCREEN) return; const [, currentY] = this.islandWindow.getPosition(); dragStartY = mouseY; windowStartY = currentY; }); // 处理拖拽移动 ipcMain.on("island:drag-move", (event, mouseY: number) => { // 只处理来自 Island 窗口的请求 if (!this.islandWindow || event.sender !== this.islandWindow.webContents) return; // 全屏模式不允许拖拽 if (this.currentMode === IslandMode.FULLSCREEN) return; const { width, height } = this.islandWindow.getBounds(); // 计算新的 Y 位置(仅垂直移动) const deltaY = mouseY - dragStartY; const newY = windowStartY + deltaY; // 锁定 X 位置到右边缘 const x = this.calculateRightAlignedX(width); // 约束 Y 在屏幕边界内 const y = this.calculateYPosition(height, newY); // 更新窗口位置 this.islandWindow.setPosition(x, y); // 发送位置更新到渲染进程 const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize; this.islandWindow.webContents.send('island:position-update', { y: y, screenHeight: screenHeight }); }); // 处理拖拽结束 ipcMain.on("island:drag-end", (event) => { // 只处理来自 Island 窗口的请求 if (!this.islandWindow || event.sender !== this.islandWindow.webContents) return; // 保存最终的 Y 位置 const [, currentY] = this.islandWindow.getPosition(); this.currentY = currentY; // 发送最终位置更新到渲染进程 const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize; this.islandWindow.webContents.send('island:position-update', { y: currentY, screenHeight: screenHeight }); }); } /** * 调整窗口到指定模式 */ resizeToMode(mode: IslandMode): void { if (!this.islandWindow) return; const validModes = Object.values(IslandMode); if (!validModes.includes(mode)) { logger.warn(`Invalid Island mode: ${mode}`); return; } this.currentMode = mode; const { width, height } = this.getSizeForMode(mode); // 形态3/4 使用正常窗口样式,形态1/2 使用透明悬浮窗样式 // SIDEBAR 模式下根据 pin 状态决定行为 const isExpandedMode = mode === IslandMode.SIDEBAR || mode === IslandMode.FULLSCREEN; const shouldAlwaysOnTop = mode === IslandMode.SIDEBAR ? this.sidebarPinned // SIDEBAR: 根据 pin 状态 : !isExpandedMode; // 其他模式: FLOAT/POPUP 为 true, FULLSCREEN 为 false // 设置窗口属性 this.islandWindow.setAlwaysOnTop(shouldAlwaysOnTop, shouldAlwaysOnTop ? "floating" : "normal"); this.islandWindow.setSkipTaskbar(shouldAlwaysOnTop); // macOS 特定:根据 pin 状态设置工作区可见性 if (process.platform === "darwin") { this.islandWindow.setVisibleOnAllWorkspaces(shouldAlwaysOnTop, { visibleOnFullScreen: shouldAlwaysOnTop }); } if (mode === IslandMode.FULLSCREEN) { // 全屏模式:覆盖整个工作区 const { x: screenX, y: screenY } = screen.getPrimaryDisplay().workArea; this.islandWindow.setBounds({ x: screenX, y: screenY, width, height }); logger.info(`Island window resized to mode: ${mode} (${width}x${height})`); // 发送锚点更新到渲染进程(全屏无锚点) this.islandWindow.webContents.send('island:anchor-update', { anchor: null, y: screenY }); } else if (mode === IslandMode.SIDEBAR) { // SIDEBAR 模式:使用智能定位算法 const x = this.calculateRightAlignedX(width); const { y, anchor } = this.calculateSmartSidebarPosition(height); this.currentY = y; // 保存位置 this.islandWindow.setBounds({ x, y, width, height }); logger.info(`Island window resized to mode: ${mode} (${width}x${height}) with ${anchor} anchor at Y=${y}`); // 发送锚点更新到渲染进程 this.islandWindow.webContents.send('island:anchor-update', { anchor: anchor, y: y }); } else { // FLOAT/POPUP 模式:右边缘对齐,保持当前 Y 位置 const x = this.calculateRightAlignedX(width); const y = this.calculateYPosition(height); this.currentY = y; // 保存位置以供下次调整使用 this.islandWindow.setBounds({ x, y, width, height }); logger.info(`Island window resized to mode: ${mode} (${width}x${height})`); // 发送锚点更新到渲染进程(FLOAT/POPUP 使用当前位置) const { height: screenHeight } = screen.getPrimaryDisplay().workAreaSize; const isInUpperHalf = y < screenHeight / 2; this.islandWindow.webContents.send('island:anchor-update', { anchor: isInUpperHalf ? 'top' : 'bottom', y: y }); } } /** * 调整 SIDEBAR 窗口到指定栏数 * @param columnCount 栏数: 1 | 2 | 3 */ resizeSidebarToColumns(columnCount: 1 | 2 | 3): void { if (!this.islandWindow) return; // 验证栏数有效性 if (columnCount < 1 || columnCount > 3) { logger.warn(`Invalid column count: ${columnCount}`); return; } // 定义各栏数的宽度 const widthMap: Record<1 | 2 | 3, number> = { 1: 420, 2: 800, 3: 1200, }; const width = widthMap[columnCount]; const height = 700; // 右边缘对齐,使用智能定位算法(如果当前是 SIDEBAR 模式) const x = this.calculateRightAlignedX(width); let y: number; if (this.currentMode === IslandMode.SIDEBAR) { // SIDEBAR 模式:使用智能定位算法 const { y: smartY, anchor } = this.calculateSmartSidebarPosition(height); y = smartY; logger.info(`Island sidebar resized to ${columnCount} column(s): ${width}x${height} with ${anchor} anchor at Y=${y}`); } else { // 其他模式:保持当前 Y 位置 y = this.calculateYPosition(height); logger.info(`Island sidebar resized to ${columnCount} column(s): ${width}x${height}`); } this.islandWindow.setBounds({ x, y, width, height }); this.currentY = y; // 保存位置 } /** * 显示 Island 窗口 */ show(): void { if (this.islandWindow && !this.islandWindow.isVisible()) { this.islandWindow.show(); this.notifyVisibilityChange(true); logger.info("Island window shown"); } } /** * 隐藏 Island 窗口 */ hide(): void { if (this.islandWindow?.isVisible()) { this.islandWindow.hide(); this.notifyVisibilityChange(false); logger.info("Island window hidden"); } } /** * 切换 Island 窗口显示/隐藏 */ toggle(): void { if (this.islandWindow) { if (this.islandWindow.isVisible()) { this.hide(); } else { this.show(); } } } /** * 销毁 Island 窗口 */ destroy(): void { if (this.islandWindow) { this.islandWindow.close(); this.islandWindow = null; } this.enabled = false; } /** * 获取 Island 窗口实例 */ getWindow(): BrowserWindow | null { return this.islandWindow; } /** * 检查 Island 是否已启用 */ isEnabled(): boolean { return this.enabled; } /** * 检查窗口是否存在 */ hasWindow(): boolean { return this.islandWindow !== null && !this.islandWindow.isDestroyed(); } /** * 获取当前模式 */ getCurrentMode(): IslandMode { return this.currentMode; } /** * 向 Island 窗口发送消息 */ sendMessage(channel: string, ...args: unknown[]): void { if (this.islandWindow && !this.islandWindow.isDestroyed()) { this.islandWindow.webContents.send(channel, ...args); } } /** * 设置可见性变化回调 * @param callback 回调函数,接收 visible 参数 */ setVisibilityChangeCallback(callback: (visible: boolean) => void): void { this.onVisibilityChange = callback; } /** * 通知可见性变化 * @param visible 当前可见性状态 */ private notifyVisibilityChange(visible: boolean): void { if (this.onVisibilityChange) { try { this.onVisibilityChange(visible); } catch (error) { logger.error( `Error in visibility change callback: ${error instanceof Error ? error.message : String(error)}`, ); } } } /** * 检查窗口当前是否可见 */ isVisible(): boolean { return this.islandWindow?.isVisible() ?? false; } /** * 设置 SIDEBAR 模式的固定状态 * @param isPinned true = 固定(始终在顶部),false = 非固定(正常窗口行为) */ setSidebarPinned(isPinned: boolean): void { if (!this.islandWindow) return; this.sidebarPinned = isPinned; // 如果当前是 SIDEBAR 模式,立即更新窗口属性 if (this.currentMode === IslandMode.SIDEBAR) { this.islandWindow.setAlwaysOnTop(isPinned, isPinned ? "floating" : "normal"); this.islandWindow.setSkipTaskbar(isPinned); // macOS 特定:根据 pin 状态设置工作区可见性 if (process.platform === "darwin") { this.islandWindow.setVisibleOnAllWorkspaces(isPinned, { visibleOnFullScreen: isPinned }); } logger.info(`Island SIDEBAR pin state changed to: ${isPinned ? "pinned" : "unpinned"}`); } } } ================================================ FILE: free-todo-frontend/electron/logger.ts ================================================ /** * Electron 主进程日志服务 * 封装日志逻辑,支持不同级别和来源标记 * 每次启动生成新的日志文件,文件名格式:YYYY-MM-DD-N.log */ import fs from "node:fs"; import path from "node:path"; import { app } from "electron"; /** * 日志级别枚举 */ type LogLevel = "INFO" | "WARN" | "ERROR" | "FATAL"; /** * 日志服务类 * 提供统一的日志记录接口,支持文件写入和控制台输出 */ class Logger { private logFile: string; private logDir: string; constructor() { this.logDir = app.getPath("logs"); this.ensureLogDir(); this.logFile = this.generateLogFileName(); this.writeStartMarker(); } /** * 确保日志目录存在 */ private ensureLogDir(): void { try { if (!fs.existsSync(this.logDir)) { fs.mkdirSync(this.logDir, { recursive: true }); } } catch { // 忽略目录创建错误 } } /** * 获取当天的日期字符串(YYYY-MM-DD) */ private getTodayDateString(): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, "0"); const day = String(now.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } /** * 生成带日期和序列号的日志文件名 * 格式:YYYY-MM-DD-N.log(N 为当天第几次启动,从 0 开始) */ private generateLogFileName(): string { const dateStr = this.getTodayDateString(); const pattern = new RegExp(`^${dateStr}-(\\d+)\\.log$`); // 扫描现有日志文件,找出当天的最大序列号 let maxSeq = -1; try { const files = fs.readdirSync(this.logDir); for (const file of files) { const match = file.match(pattern); if (match) { const seq = Number.parseInt(match[1], 10); if (seq > maxSeq) { maxSeq = seq; } } } } catch { // 忽略读取错误 } // 新的序列号 = 最大序列号 + 1 const newSeq = maxSeq + 1; const fileName = `${dateStr}-${newSeq}.log`; return path.join(this.logDir, fileName); } /** * 写入启动标记 */ private writeStartMarker(): void { try { const timestamp = new Date().toISOString(); const marker = `\n${"=".repeat(80)}\n` + `[${timestamp}] [INFO] Application started - Log file: ${path.basename(this.logFile)}\n` + `${"=".repeat(80)}\n\n`; fs.writeFileSync(this.logFile, marker); } catch { // 忽略写入错误 } } /** * 写入日志到文件 */ private write(level: LogLevel, message: string, source?: string): void { try { const timestamp = new Date().toISOString(); const sourceTag = source ? `[${source}] ` : ""; const logLine = `[${timestamp}] [${level}] ${sourceTag}${message}\n`; fs.appendFileSync(this.logFile, logLine); } catch { // 忽略写入错误 } } /** * 获取日志文件路径 */ getLogFilePath(): string { return this.logFile; } /** * 记录信息级别日志 */ info(message: string, source?: string): void { this.write("INFO", message, source); } /** * 记录警告级别日志 */ warn(message: string, source?: string): void { this.write("WARN", message, source); } /** * 记录错误级别日志 */ error(message: string, source?: string): void { this.write("ERROR", message, source); } /** * 记录致命错误级别日志 */ fatal(message: string, source?: string): void { this.write("FATAL", message, source); } /** * 记录子进程标准输出 */ stdout(source: string, data: string): void { const trimmed = data.trim(); if (trimmed) { this.write("INFO", trimmed, `${source} STDOUT`); } } /** * 记录子进程标准错误输出 */ stderr(source: string, data: string): void { const trimmed = data.trim(); if (trimmed) { this.write("INFO", trimmed, `${source} STDERR`); } } /** * 记录带堆栈信息的错误 */ errorWithStack(message: string, error: Error, source?: string): void { this.error(message, source); if (error.stack) { this.error(`Stack: ${error.stack}`, source); } } /** * 同时输出到控制台和日志文件 */ console(message: string, source?: string): void { console.log(message); this.info(message, source); } /** * 同时输出错误到控制台和日志文件 */ consoleError(message: string, source?: string): void { console.error(message); this.error(message, source); } /** * 写入结束标记(在应用退出时调用) */ writeEndMarker(): void { try { const timestamp = new Date().toISOString(); const marker = `\n${"=".repeat(80)}\n` + `[${timestamp}] [INFO] Application ended\n` + `${"=".repeat(80)}\n`; fs.appendFileSync(this.logFile, marker); } catch { // 忽略写入错误 } } } /** * 全局日志服务实例 */ export const logger = new Logger(); ================================================ FILE: free-todo-frontend/electron/main.ts ================================================ /** * Electron 主进程入口 * 应用启动协调层,负责初始化各模块并管理应用生命周期 */ // Set console encoding to UTF-8 for Windows if (process.platform === "win32") { try { // Try to set console code page to UTF-8 require("node:child_process").exec("chcp 65001", () => {}); } catch { // Ignore errors } } import path from "node:path"; import { app, dialog, ipcMain } from "electron"; import { BackendServer } from "./backend-server"; import { cancelBootstrap } from "./bootstrap-control"; import { emitComplete, emitStatus } from "./bootstrap-status"; import { closeBootstrapWindow, createBootstrapWindow, getBootstrapWindow } from "./bootstrap-window"; import { getBackendRuntime, getServerMode, getWindowMode, isDevelopment, PROCESS_CONFIG, TIMEOUT_CONFIG, } from "./config"; import { GlobalShortcutManager } from "./global-shortcut-manager"; import { setupIpcHandlers } from "./ipc-handlers"; import { IslandWindowManager } from "./island-window-manager"; import { logger } from "./logger"; import { getServerUrl, setBackendUrl, startNextServer, stopNextServer, waitForServerPublic, } from "./next-server"; import { requestNotificationPermission } from "./notification"; import { isRuntimePrepared, setPreferredPythonPath, validatePythonPath } from "./python-runtime"; import { getInstallRoot, resolveRuntimeRoot } from "./runtime-paths"; import { TrayManager } from "./tray-manager"; import { WindowManager } from "./window-manager"; // 判断是否为开发模式 const isDev = isDevelopment(app.isPackaged); // 获取服务器模式 const serverMode = getServerMode(); // 获取窗口模式(island 或 web) const windowMode = getWindowMode(); let bootstrapCompleted = false; let stopPromptOpen = false; // 确保只有相同模式的应用实例运行 // DEV 和 Build 版本使用不同的锁名称,允许它们同时运行 // 但同一模式下只允许一个实例 const lockName = `freetodo-${serverMode}`; const gotTheLock = app.requestSingleInstanceLock({ lockName } as never); if (!gotTheLock) { // 如果已经有实例在运行,退出当前实例 app.quit(); } else { // 初始化各管理器实例 const backendServer = new BackendServer(); const windowManager = new WindowManager(); const islandWindowManager = new IslandWindowManager(); // 初始化 Tray 和 GlobalShortcut 管理器(在 Island 创建后初始化) let trayManager: TrayManager | null = null; let shortcutManager: GlobalShortcutManager | null = null; // 设置全局异常处理 setupGlobalErrorHandlers(); // 处理 Ctrl+C (SIGINT) 和 SIGTERM 信号,确保正常退出 let isQuitting = false; const gracefulShutdown = async (signal: string) => { if (isQuitting) { console.log(`\nReceived ${signal} signal again, forcing exit...`); process.exit(1); return; } isQuitting = true; console.log(`\nReceived ${signal} signal, shutting down gracefully...`); try { // Only stop frontend server (Next.js), backend doesn't need to stop console.log("\nStopping Next.js server..."); stopNextServer(); const { getNextProcess } = await import("./next-server"); const nextProcess = getNextProcess(); if (nextProcess && !nextProcess.killed) { // Wait for Next.js process to exit (this is critical) await new Promise((resolve) => { const timeout = setTimeout(() => { console.log("Next.js process did not exit within 5 seconds, forcing exit..."); if (nextProcess && !nextProcess.killed) { try { // On Windows, use SIGKILL to force kill if (process.platform === "win32") { nextProcess.kill("SIGKILL"); } else { nextProcess.kill("SIGKILL"); } } catch (err) { console.warn(`Failed to kill Next.js process: ${err instanceof Error ? err.message : String(err)}`); } } resolve(); }, 5000); nextProcess.once("exit", () => { clearTimeout(timeout); console.log("Next.js process exited successfully"); resolve(); }); }); } else { console.log("Next.js process already stopped"); } console.log("Frontend process stopped, exiting..."); // Ensure app exits setTimeout(() => { app.quit(); process.exit(0); }, 100); } catch (error) { console.error( `Error during graceful shutdown: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } }; // 监听 SIGINT (Ctrl+C) 和 SIGTERM 信号 process.on("SIGINT", () => gracefulShutdown("SIGINT")); process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); // 当另一个实例尝试启动时,聚焦到主窗口 app.on("second-instance", () => { if (windowMode === "web") { // Web 模式:使用普通窗口 if (windowManager.hasWindow()) { windowManager.focus(); } else if (app.isReady()) { windowManager.create(getServerUrl()); } else { app.once("ready", () => { windowManager.create(getServerUrl()); }); } } else { // Island 模式:使用灵动岛窗口 if (islandWindowManager.hasWindow()) { islandWindowManager.show(); islandWindowManager.getWindow()?.focus(); } else if (app.isReady()) { islandWindowManager.create(getServerUrl()); } else { app.once("ready", () => { islandWindowManager.create(getServerUrl()); }); } } }); // macOS: 点击 dock 图标时显示或重建窗口 app.on("activate", () => { if (windowMode === "web") { // Web 模式:使用普通窗口 if (windowManager.hasWindow()) { windowManager.focus(); } else { windowManager.create(getServerUrl()); } } else { // Island 模式:使用灵动岛窗口 if (islandWindowManager.hasWindow()) { islandWindowManager.show(); } else { islandWindowManager.create(getServerUrl()); } } }); // 所有窗口关闭时退出应用(macOS 除外) app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); // 应用退出前清理(不等待,快速退出) app.on("before-quit", () => { cleanup(backendServer, trayManager, shortcutManager, false); }); // 应用退出时确保清理(不等待,快速退出) app.on("quit", () => { cleanup(backendServer, trayManager, shortcutManager, false); }); // 应用准备就绪后启动 app.whenReady().then(async () => { if (app.isPackaged) { const backendRuntime = getBackendRuntime(); if (backendRuntime === "script") { const runtimeRoot = resolveRuntimeRoot(); const venvDir = path.join(runtimeRoot, PROCESS_CONFIG.backendVenvDir); const requirementsPath = app.isPackaged ? path.join(process.resourcesPath, "backend", PROCESS_CONFIG.backendRequirementsFile) : path.join(getInstallRoot(), PROCESS_CONFIG.backendRequirementsFile); if (!isRuntimePrepared(runtimeRoot, venvDir, requirementsPath)) { createBootstrapWindow(); attachBootstrapHandlers(); } else { bootstrapCompleted = true; } } else { bootstrapCompleted = true; } } const managers = await bootstrap(backendServer, windowManager, islandWindowManager); trayManager = managers.trayManager; shortcutManager = managers.shortcutManager; }); } /** * 设置全局错误处理器 */ function setupGlobalErrorHandlers(): void { process.on("uncaughtException", (error) => { logger.fatal(`UNCAUGHT EXCEPTION: ${error.message}`); if (error.stack) { logger.fatal(`Stack: ${error.stack}`); } }); process.on("unhandledRejection", (reason) => { logger.fatal(`UNHANDLED REJECTION: ${reason}`); }); } function attachBootstrapHandlers(): void { const bootstrapWindow = getBootstrapWindow(); if (bootstrapWindow) { bootstrapWindow.on("close", async (event) => { if (bootstrapCompleted) { return; } event.preventDefault(); await confirmStopInstallation(); }); } ipcMain.removeAllListeners("bootstrap:stop"); ipcMain.removeAllListeners("bootstrap:select-python"); ipcMain.on("bootstrap:stop", async () => { await confirmStopInstallation(); }); ipcMain.on("bootstrap:select-python", async () => { const dialogOptions: Electron.OpenDialogOptions = { properties: ["openFile"], title: "选择 Python 3.12 可执行文件", }; if (process.platform === "win32") { dialogOptions.filters = [{ name: "Python", extensions: ["exe"] }]; } const result = await dialog.showOpenDialog(dialogOptions); if (result.canceled || result.filePaths.length === 0) { return; } const selectedPath = result.filePaths[0]; const info = await validatePythonPath(selectedPath); if (!info || !info.version.startsWith("3.12")) { dialog.showErrorBox( "Python 版本不匹配", "请选择 Python 3.12 的可执行文件。", ); return; } setPreferredPythonPath(selectedPath); emitStatus({ message: "已选择 Python 3.12", pythonPath: info.executable, }); }); } async function confirmStopInstallation(): Promise { if (bootstrapCompleted || stopPromptOpen) { return; } stopPromptOpen = true; const result = await dialog.showMessageBox({ type: "warning", buttons: ["继续等待", "停止安装"], defaultId: 0, cancelId: 0, message: "确定要停止安装 FreeTodo 吗?", detail: "停止后需要重新启动安装流程。", }); stopPromptOpen = false; if (result.response === 1) { emitStatus({ message: "正在停止安装", progress: 0 }); cancelBootstrap(); app.quit(); } } function waitForBootstrapContinue(): Promise { return new Promise((resolve) => { ipcMain.once("bootstrap:continue", () => resolve()); }); } /** * 应用启动流程 */ async function bootstrap( backendServer: BackendServer, windowManager: WindowManager, islandWindowManager: IslandWindowManager, ): Promise<{ trayManager: TrayManager; shortcutManager: GlobalShortcutManager }> { try { // 记录启动信息 logStartupInfo(); const installPath = getInstallRoot(); const runtimeRoot = resolveRuntimeRoot(); const venvPath = path.join(runtimeRoot, PROCESS_CONFIG.backendVenvDir); emitStatus({ message: "启动初始化", progress: 0, installPath, venvPath, }); // 设置 IPC 处理器(包含 Island 相关) setupIpcHandlers(windowManager, islandWindowManager); // 请求通知权限 await requestNotificationPermission(); // 1. 自动检测后端端口(如果后端已运行) logger.info("Detecting running backend server..."); emitStatus({ message: "检测后端服务", progress: 15 }); const detectedBackendPort = await backendServer.detectRunningBackendPort(); if (detectedBackendPort) { backendServer.setPort(detectedBackendPort); logger.info(`Detected backend running on port: ${detectedBackendPort}`); emitStatus({ message: "检测到已运行后端", progress: 20 }); } else { // 如果检测不到,启动后端服务器 logger.info("No running backend detected, will start backend server..."); await backendServer.start({ waitForReady: false }); } // 更新 NextServer 的后端 URL(后端可能使用了动态端口) const backendUrl = backendServer.getUrl(); setBackendUrl(backendUrl); // 2. 启动 Next.js 前端服务器(无需等待后端完全就绪) await startNextServer(); const serverUrl = getServerUrl(); // 3. 根据窗口模式创建主窗口(先展示加载界面) if (windowMode === "web") { windowManager.create(serverUrl, { waitForServer: false, showLoading: true }); logger.info("Web main window created (loading)"); } // 并行等待后端与前端就绪 const backendReadyPromise = backendServer .waitForReadyAndVerify(TIMEOUT_CONFIG.backendReady * 6) .then(() => { logger.console(`Backend server is ready at ${backendUrl}!`); emitStatus({ message: "后端健康检查通过", progress: 80 }); }) .catch((error) => { const errorMsg = `Backend server not available: ${error instanceof Error ? error.message : String(error)}`; logger.warn(errorMsg); if (!isDev) { throw error; } }); const frontendReadyPromise = waitForServerPublic(serverUrl, 30000) .then(() => { logger.console(`Next.js server is ready at ${serverUrl}!`); emitStatus({ message: "前端服务已就绪", progress: 92 }); }) .catch((error) => { const errorMsg = `Next.js server did not start within 30000ms: ${error instanceof Error ? error.message : String(error)}`; logger.error(errorMsg); if (!isDev) { throw error; } }); await Promise.all([backendReadyPromise, frontendReadyPromise]); // 4. 根据窗口模式创建主窗口 if (app.isPackaged && !bootstrapCompleted) { emitStatus({ message: "安装完成", detail: "点击“开始使用”进入应用", progress: 100, }); emitComplete(); await waitForBootstrapContinue(); bootstrapCompleted = true; } if (windowMode === "web") { // Web 模式:创建普通窗口,加载主页面 if (!windowManager.hasWindow()) { windowManager.create(serverUrl); } else { windowManager.load(serverUrl); } logger.info("Web main window created"); } else { // Island 模式:创建灵动岛窗口 islandWindowManager.create(serverUrl); logger.info("Island main window created"); } closeBootstrapWindow(); // 5. 初始化 Tray 和 Global Shortcuts // 注意:Web 模式下 TrayManager 和 GlobalShortcutManager 仍然使用 islandWindowManager // 这样即使在 Web 模式下,用户也可以通过快捷键或托盘切换到 Island 模式 const trayManager = new TrayManager(islandWindowManager); trayManager.create(); logger.info("System tray icon created"); const shortcutManager = new GlobalShortcutManager(islandWindowManager); shortcutManager.registerDefaults(); logger.info("Global shortcuts registered"); logger.info( `Window created successfully. Frontend: ${getServerUrl()}, Backend: ${backendServer.getUrl()}`, ); return { trayManager, shortcutManager }; } catch (error) { handleStartupError(error); // Return dummy instances on error (will be cleaned up) return { trayManager: new TrayManager(islandWindowManager), shortcutManager: new GlobalShortcutManager(islandWindowManager), }; } } /** * 记录启动信息 */ function logStartupInfo(): void { logger.info("Application starting..."); logger.info(`App isPackaged: ${app.isPackaged}`); logger.info(`NODE_ENV: ${process.env.NODE_ENV || "not set"}`); logger.info(`isDev: ${isDev}`); logger.info(`Server mode: ${serverMode}`); logger.info(`Window mode: ${windowMode}`); logger.info(`Will start built-in server: ${!isDev || app.isPackaged}`); } /** * 处理启动错误 */ function handleStartupError(error: unknown): void { const errorMsg = `Failed to start application: ${error instanceof Error ? error.message : String(error)}`; console.error(errorMsg); logger.fatal(errorMsg); if (error instanceof Error && error.stack) { logger.fatal(`Stack trace: ${error.stack}`); } dialog.showErrorBox( "Startup Error", `Failed to start application:\n${errorMsg}\n\nCheck logs at: ${logger.getLogFilePath()}`, ); setTimeout(() => { app.quit(); }, TIMEOUT_CONFIG.quitDelay); } /** * 清理资源 * @param backendServer 后端服务器实例 * @param trayManager Tray 管理器实例 * @param shortcutManager 全局快捷键管理器实例 * @param waitForExit 是否等待进程退出(默认 false,用于快速退出) */ function cleanup( backendServer: BackendServer, trayManager: TrayManager | null, shortcutManager: GlobalShortcutManager | null, waitForExit = false, ): void { logger.info("Cleaning up resources..."); // 清理 Tray if (trayManager) { trayManager.destroy(); } // 清理全局快捷键(在 GlobalShortcutManager 中已自动注册清理) if (shortcutManager) { shortcutManager.unregisterAll(); } // 如果 waitForExit 为 false,快速停止(不等待) backendServer.stop(waitForExit); stopNextServer(); } ================================================ FILE: free-todo-frontend/electron/next-server.ts ================================================ /** * Next.js 服务器管理模块 * 负责 Next.js 服务器的启动、停止和进程管理 */ import { type ChildProcess, fork, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { app, BrowserWindow, dialog } from "electron"; import { emitStatus } from "./bootstrap-status"; import { isDevelopment, LOG_CONFIG, PORT_CONFIG, } from "./config"; import { logger } from "./logger"; import { portManager } from "./port-manager"; // 需要从 health-check 导入的函数(如果不存在则创建) // 暂时使用内联实现,后续可以提取到 health-check.ts function setNextProcessRef(_proc: { killed: boolean } | null): void { // 设置进程引用(用于健康检查,如果需要) } function stopHealthCheck(): void { // 健康检查停止逻辑(如果需要) } function waitForServer(url: string, timeout: number): Promise { return new Promise((resolve, reject) => { const startTime = Date.now(); const http = require("node:http") as typeof import("node:http"); const check = () => { http .get(url, (res: import("node:http").IncomingMessage) => { if (res.statusCode === 200 || res.statusCode === 304) { resolve(); } else { retry(); } }) .on("error", () => { retry(); }); }; const retry = () => { if (Date.now() - startTime >= timeout) { reject(new Error(`Server did not start within ${timeout}ms`)); } else { setTimeout(check, 500); } }; check(); }); } let nextProcess: ChildProcess | null = null; let isStopping = false; /** * 获取 Next.js 进程 */ export function getNextProcess(): ChildProcess | null { return nextProcess; } /** * 设置 Next.js 进程 */ export function setNextProcess(proc: ChildProcess | null): void { nextProcess = proc; setNextProcessRef(proc); } // 动态端口(运行时确定) let actualFrontendPort: number = PORT_CONFIG.frontend.default; /** * 获取当前前端端口 */ function getActualFrontendPort(): number { return actualFrontendPort; } /** * 设置前端端口 */ function setActualFrontendPort(port: number): void { actualFrontendPort = port; } /** * 获取后端服务器 URL(需要从外部传入) */ let backendUrl = "http://localhost:8000"; /** * 设置后端 URL */ export function setBackendUrl(url: string): void { backendUrl = url; } /** * 获取后端服务器 URL */ export function getBackendUrl(): string { return backendUrl; } /** * 启动 Next.js 服务器(支持动态端口) * 在打包的应用中,总是启动内置的生产服务器 */ export async function startNextServer(): Promise { const isDev = isDevelopment(app.isPackaged); emitStatus({ message: "启动前端服务", progress: 82 }); // 如果应用已打包,必须启动内置服务器,不允许依赖外部 dev 服务器 if (app.isPackaged) { logger.info("App is packaged - starting built-in production server"); } else if (isDev) { // 开发模式下,尝试探测可用的前端端口(以防开发服务器未启动) try { const port = await portManager.findAvailablePort( PORT_CONFIG.frontend.default, ); setActualFrontendPort(port); } catch { setActualFrontendPort(PORT_CONFIG.frontend.default); } const serverUrl = getServerUrl(); const msg = `Development mode: expecting Next.js dev server at ${serverUrl}`; logger.console(msg); logger.info(msg); // 检查是否已经有 Next.js 服务器在运行 try { await waitForServer(serverUrl, 2000); logger.info("Next.js dev server is already running"); return; } catch { // 没有运行,需要启动 } // 启动 Next.js dev 服务器 // 在 Windows 上,需要使用 shell: true 来运行 .cmd 文件 const devCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; const devArgs = ["dev"]; logger.info( `Starting Next.js dev server: ${devCommand} ${devArgs.join(" ")}`, ); logger.info(`Working directory: ${path.join(__dirname, "..")}`); // Set console encoding to UTF-8 for Windows if (process.platform === "win32") { try { // Try to set console code page to UTF-8 require("node:child_process").exec("chcp 65001", () => {}); } catch { // Ignore errors } } nextProcess = spawn(devCommand, devArgs, { cwd: path.join(__dirname, ".."), env: { ...process.env, PORT: String(getActualFrontendPort()), NODE_ENV: "development", // Set UTF-8 encoding for child process ...(process.platform === "win32" && { CHCP: "65001" }), }, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", // Windows needs shell detached: false, // Ensure child process is part of the same process group }); setNextProcessRef(nextProcess); // Listen to output - output directly to console, don't log to file (avoid garbled characters) if (nextProcess.stdout) { nextProcess.stdout.setEncoding("utf8"); nextProcess.stdout.on("data", (data) => { const output = String(data); // Output directly to console (just like pnpm dev) // Use Buffer to ensure correct encoding try { process.stdout.write(Buffer.from(output, "utf8")); } catch { process.stdout.write(output); } }); } if (nextProcess.stderr) { nextProcess.stderr.setEncoding("utf8"); nextProcess.stderr.on("data", (data) => { const output = String(data); // Output directly to console (just like pnpm dev) // Use Buffer to ensure correct encoding try { process.stderr.write(Buffer.from(output, "utf8")); } catch { process.stderr.write(output); } }); } nextProcess.on("error", (error) => { logger.error(`Failed to start Next.js dev server: ${error.message}`); }); nextProcess.on("exit", (code) => { logger.error(`Next.js dev server exited with code ${code}`); }); return; } else { logger.info( "Running in production mode (not packaged) - starting built-in server", ); } // 动态端口分配:查找可用的前端端口 try { const port = await portManager.findAvailablePort( PORT_CONFIG.frontend.default, ); setActualFrontendPort(port); logger.info(`Frontend will use port: ${port}`); } catch (error) { const errorMsg = `Failed to find available frontend port: ${error instanceof Error ? error.message : String(error)}`; logger.error(errorMsg); dialog.showErrorBox("Port Allocation Error", errorMsg); throw error; } const serverPath = path.join( process.resourcesPath, "standalone", "server.js", ); const msg = `Starting Next.js server from: ${serverPath}`; logger.console(msg); logger.info(msg); emitStatus({ message: "启动前端服务", progress: 85, detail: serverPath }); // 检查服务器文件是否存在 if (!fs.existsSync(serverPath)) { const errorMsg = `Server file not found: ${serverPath}`; logger.error(errorMsg); dialog.showErrorBox( "Server Not Found", `The Next.js server file was not found at:\n${serverPath}\n\nPlease rebuild the application.`, ); throw new Error(errorMsg); } // 设置工作目录为 standalone 目录,这样相对路径可以正确解析 const serverDir = path.dirname(serverPath); logger.info(`Server directory: ${serverDir}`); logger.info(`Server path: ${serverPath}`); logger.info(`PORT: ${getActualFrontendPort()}, HOSTNAME: localhost`); logger.info(`NEXT_PUBLIC_API_URL: ${getBackendUrl()}`); // 检查关键文件是否存在 const nextServerDir = path.join(serverDir, ".next", "server"); if (!fs.existsSync(nextServerDir)) { const errorMsg = `Required directory not found: ${nextServerDir}`; logger.error(errorMsg); throw new Error(errorMsg); } logger.info("Verified .next/server directory exists"); // 强制设置生产环境变量,确保服务器以生产模式运行 // 创建新的环境对象,避免直接修改 process.env const serverEnv: Record = {}; // 复制所有环境变量,但排除 dev 相关变量 for (const key in process.env) { if (!key.startsWith("NEXT_DEV") && !key.startsWith("TURBOPACK")) { serverEnv[key] = process.env[key]; } } // 强制设置生产模式环境变量,使用动态分配的端口 serverEnv.PORT = String(getActualFrontendPort()); serverEnv.HOSTNAME = "localhost"; serverEnv.NODE_ENV = "production"; // 强制生产模式 // 注入后端 URL,让 Next.js 的 rewrite 和 API 调用使用正确的后端地址 serverEnv.NEXT_PUBLIC_API_URL = getBackendUrl(); // 使用 fork 启动 Node.js 服务器进程 // fork 是 spawn 的特殊情况,专门用于 Node.js 脚本,提供更好的 IPC 支持 // 注意:fork 会自动设置 execPath,所以我们只需要传递脚本路径 nextProcess = fork(serverPath, [], { cwd: serverDir, // 设置工作目录 env: serverEnv as NodeJS.ProcessEnv, stdio: ["ignore", "pipe", "pipe", "ipc"], // stdin: ignore, stdout/stderr: pipe, ipc channel silent: false, // 不静默,允许输出 }); setNextProcessRef(nextProcess); logger.info(`Spawned process with PID: ${nextProcess.pid}`); // 确保进程引用被保持 if (!nextProcess.pid) { const errorMsg = "Failed to spawn process - no PID assigned"; logger.error(errorMsg); throw new Error(errorMsg); } // 监听进程的 spawn 事件 nextProcess.on("spawn", () => { logger.info(`Process spawned successfully with PID: ${nextProcess?.pid}`); }); // 收集所有输出用于日志 let stdoutBuffer = ""; let stderrBuffer = ""; // 立即设置数据监听器,避免丢失早期输出 // 直接输出到控制台,不记录到日志文件(避免乱码) if (nextProcess.stdout) { nextProcess.stdout.setEncoding("utf8"); nextProcess.stdout.on("data", (data) => { const output = String(data); stdoutBuffer += output; // 直接输出到控制台 process.stdout.write(output); }); nextProcess.stdout.on("end", () => { logger.info("[Next.js STDOUT] stream ended"); }); nextProcess.stdout.on("error", (err) => { logger.error(`[Next.js STDOUT] stream error: ${err.message}`); }); } if (nextProcess.stderr) { nextProcess.stderr.setEncoding("utf8"); nextProcess.stderr.on("data", (data) => { const output = String(data); stderrBuffer += output; // 直接输出到控制台 process.stderr.write(output); }); nextProcess.stderr.on("end", () => { logger.info("[Next.js STDERR] stream ended"); }); nextProcess.stderr.on("error", (err) => { logger.error(`[Next.js STDERR] stream error: ${err.message}`); }); } nextProcess.on("error", (error) => { const errorMsg = `Failed to start Next.js server: ${error.message}`; logger.error(errorMsg); if (error.stack) { logger.error(`Error stack: ${error.stack}`); } // 显示错误对话框 const windows = BrowserWindow.getAllWindows(); if (windows.length > 0) { dialog.showErrorBox( "Server Start Error", `Failed to start Next.js server:\n${error.message}\n\nCheck logs at: ${logger.getLogFilePath()}`, ); } try { console.error(errorMsg, error); } catch { // 忽略 EPIPE 错误 } }); // 监听未捕获的异常(可能在子进程中) process.on("uncaughtException", (error) => { logger.error(`UNCAUGHT EXCEPTION: ${error.message}`); if (error.stack) { logger.error(`Stack: ${error.stack}`); } }); process.on("unhandledRejection", (reason) => { logger.error(`UNHANDLED REJECTION: ${reason}`); }); nextProcess.on("exit", (code, signal) => { const exitMsg = `Next.js server exited with code ${code}, signal ${signal}`; // 如果是主动关闭(调用了 stop() 方法),不显示错误对话框 if (isStopping) { logger.info(`${exitMsg} (intentional shutdown)`); isStopping = false; // 重置标志 return; } logger.error(exitMsg); logger.info( `STDOUT buffer (last ${LOG_CONFIG.bufferDisplayLimit} chars): ${stdoutBuffer.slice(-LOG_CONFIG.bufferDisplayLimit)}`, ); logger.info( `STDERR buffer (last ${LOG_CONFIG.bufferDisplayLimit} chars): ${stderrBuffer.slice(-LOG_CONFIG.bufferDisplayLimit)}`, ); // 检查 node_modules 是否存在 const nodeModulesPath = path.join(serverDir, "node_modules"); const nextModulePath = path.join(nodeModulesPath, "next"); logger.info(`Checking node_modules: ${nodeModulesPath}`); logger.info(`node_modules exists: ${fs.existsSync(nodeModulesPath)}`); logger.info(`next module exists: ${fs.existsSync(nextModulePath)}`); // 检查关键依赖 const styledJsxPath = path.join(nodeModulesPath, "styled-jsx"); const swcHelpersPath = path.join(nodeModulesPath, "@swc", "helpers"); logger.info(`styled-jsx exists: ${fs.existsSync(styledJsxPath)}`); logger.info(`@swc/helpers exists: ${fs.existsSync(swcHelpersPath)}`); // 如果服务器在启动后很快退出(无论是 code 0 还是其他),都认为是错误 // 因为服务器应该持续运行 const errorMsg = `Server exited unexpectedly with code ${code}${signal ? `, signal ${signal}` : ""}. Check logs at: ${logger.getLogFilePath()}`; logger.error(errorMsg); const windows = BrowserWindow.getAllWindows(); if (windows.length > 0) { dialog.showErrorBox( "Server Exited Unexpectedly", `The Next.js server exited unexpectedly.\n\n${errorMsg}\n\nSTDOUT:\n${stdoutBuffer.slice(-LOG_CONFIG.dialogDisplayLimit) || "(empty)"}\n\nSTDERR:\n${stderrBuffer.slice(-LOG_CONFIG.dialogDisplayLimit) || "(empty)"}\n\nCheck logs at: ${logger.getLogFilePath()}`, ); } // 延迟退出,让用户看到错误消息 setTimeout(() => { app.quit(); }, 3000); }); } /** * 关闭 Next.js 服务器 * 注意:这个函数只发送停止信号,不等待进程退出 * 实际的等待逻辑在 cleanup 函数中处理 */ export function stopNextServer(): void { isStopping = true; stopHealthCheck(); if (nextProcess && !nextProcess.killed) { logger.info("Stopping Next.js server..."); try { // 发送优雅关闭信号(SIGTERM) nextProcess.kill("SIGTERM"); } catch (error) { logger.error( `Error stopping Next.js server: ${error instanceof Error ? error.message : String(error)}`, ); } // 不立即设置为 null,让 cleanup 函数可以等待进程退出 } } /** * 获取服务器 URL(用于外部调用) */ export function getServerUrl(): string { return `http://localhost:${actualFrontendPort}`; } /** * 等待服务器就绪(公共方法) */ export async function waitForServerPublic( url: string, timeout: number, ): Promise { await waitForServer(url, timeout); } ================================================ FILE: free-todo-frontend/electron/notification.ts ================================================ /** * 系统通知服务 * 提供系统原生通知功能 */ import { Notification } from "electron"; import { logger } from "./logger"; import type { WindowManager } from "./window-manager"; /** * 通知数据接口 */ export interface NotificationData { /** 通知 ID */ id: string; /** 通知标题 */ title: string; /** 通知内容 */ content: string; /** 时间戳 */ timestamp: string; } /** * 请求通知权限 * 注意:Electron 会在首次显示通知时自动请求权限,无需手动检查 * macOS 10.14+ 会弹出权限请求对话框 * Windows 和 Linux 通常不需要显式权限请求 */ export async function requestNotificationPermission(): Promise { logger.info( "Notification permission will be requested automatically on first notification", ); } /** * 显示系统通知 * @param data 通知数据 * @param windowManager 窗口管理器(用于点击通知时聚焦窗口) */ export function showSystemNotification( data: NotificationData, windowManager: WindowManager, ): void { if (!windowManager.hasWindow()) { logger.warn("Cannot show notification - mainWindow is null"); return; } try { const notification = new Notification({ title: data.title, body: data.content, silent: false, // 允许通知声音 }); // 处理通知点击事件 notification.on("click", () => { logger.info(`Notification ${data.id} clicked - focusing window`); windowManager.focus(); }); // 处理通知显示事件 notification.on("show", () => { logger.info(`Notification ${data.id} shown: ${data.title}`); }); // 处理通知关闭事件 notification.on("close", () => { logger.info(`Notification ${data.id} closed`); }); // 显示通知 notification.show(); } catch (error) { const errorMsg = `Failed to show notification: ${error instanceof Error ? error.message : String(error)}`; logger.error(errorMsg); // 静默失败,不影响应用运行 } } ================================================ FILE: free-todo-frontend/electron/port-manager.ts ================================================ /** * 端口管理服务 * 提供端口可用性检测和动态端口分配功能 */ import net from "node:net"; import { PORT_CONFIG } from "./config"; import { logger } from "./logger"; /** * 端口管理器类 * 负责检测端口可用性和查找可用端口 */ class PortManager { /** * 检查指定端口是否可用 * @param port 要检查的端口号 * @returns 端口是否可用 */ async isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); server.once("error", () => resolve(false)); server.once("listening", () => { server.close(); resolve(true); }); server.listen(port, "127.0.0.1"); }); } /** * 查找可用端口 * 从 startPort 开始,依次尝试直到找到可用端口 * @param startPort 起始端口号 * @param maxAttempts 最大尝试次数,默认 100 * @returns 可用的端口号 * @throws 如果在指定范围内找不到可用端口 */ async findAvailablePort( startPort: number, maxAttempts: number = PORT_CONFIG.frontend.maxAttempts, ): Promise { for (let offset = 0; offset < maxAttempts; offset++) { const port = startPort + offset; if (await this.isPortAvailable(port)) { if (offset > 0) { logger.info( `Port ${startPort} was occupied, using port ${port} instead`, ); } return port; } logger.info(`Port ${port} is occupied, trying next...`); } throw new Error( `No available port found in range ${startPort}-${startPort + maxAttempts}`, ); } } /** * 全局端口管理器实例 */ export const portManager = new PortManager(); ================================================ FILE: free-todo-frontend/electron/preload.ts ================================================ /** * Electron Preload Script * 用于在渲染进程中安全地访问 Electron API */ import { contextBridge, ipcRenderer } from "electron"; /** * 通知数据接口 */ export interface NotificationData { id: string; title: string; content: string; timestamp: string; } // 立即设置透明背景(在页面加载前执行) // 这样可以避免 Next.js SSR 导致的窗口显示问题 (() => { function setTransparentBackground() { // 检查 DOM 是否可用 if (typeof document === "undefined" || !document.documentElement) { return; } // 立即设置透明背景,使用 !important const html = document.documentElement; const body = document.body; if (html) { html.setAttribute("data-electron", "true"); html.style.setProperty("background-color", "transparent", "important"); html.style.setProperty("background", "transparent", "important"); } if (body) { body.style.setProperty("background-color", "transparent", "important"); body.style.setProperty("background", "transparent", "important"); } // 原来这里会直接通知主进程 "transparent-background-ready" // 但这发生在 React 完成水合之前,可能导致窗口过早显示整页 UI // 现在改为只由前端的 ElectronTransparentScript 通知,确保已进入 Electron 专用布局后再显示窗口 } // 等待 DOM 可用后再执行 if (typeof document !== "undefined") { // 如果 DOM 已经加载完成 if ( document.readyState === "complete" || document.readyState === "interactive" ) { setTransparentBackground(); } else { // 监听 DOMContentLoaded document.addEventListener("DOMContentLoaded", setTransparentBackground, { once: true, }); } // 也监听 body 的创建(如果 body 还不存在) if (!document.body && document.documentElement) { const observer = new MutationObserver((_mutations, obs) => { if (document.body) { setTransparentBackground(); obs.disconnect(); } }); // 确保 documentElement 存在且是有效的 Node if (document.documentElement && document.documentElement.nodeType === 1) { observer.observe(document.documentElement, { childList: true, subtree: true, }); } } else if (document.body) { // body 已存在,直接设置 setTransparentBackground(); } } })(); // 暴露安全的 API 给渲染进程 contextBridge.exposeInMainWorld("electronAPI", { /** * 显示系统通知 * @param data 通知数据 * @returns Promise */ showNotification: (data: NotificationData): Promise => { return ipcRenderer.invoke("show-notification", data); }, /** * 设置窗口是否忽略鼠标事件(用于透明窗口点击穿透) */ setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { ipcRenderer.send("set-ignore-mouse-events", ignore, options); }, /** * 获取屏幕信息 */ getScreenInfo: () => ipcRenderer.invoke("get-screen-info"), /** * 通知主进程透明背景已设置完成 */ transparentBackgroundReady: () => { ipcRenderer.send("transparent-background-ready"); }, /** * 移动窗口到指定位置 */ moveWindow: (x: number, y: number) => { ipcRenderer.send("move-window", x, y); }, /** * 获取窗口当前位置 */ getWindowPosition: async () => { return await ipcRenderer.invoke("get-window-position"); }, /** * 退出应用 */ quit: () => { ipcRenderer.send("app-quit"); }, /** * 设置窗口背景色 */ setWindowBackgroundColor: (color: string) => { ipcRenderer.send("set-window-background-color", color); }, /** * 截图并提取待办事项 */ captureAndExtractTodos: async ( panelBounds?: { x: number; y: number; width: number; height: number } | null, ): Promise<{ success: boolean; message: string; extractedTodos: Array<{ title: string; description?: string; time_info?: Record; source_text?: string; confidence: number; }>; createdCount: number; }> => { return await ipcRenderer.invoke("capture-and-extract-todos", panelBounds); }, // ========== Island 动态岛相关 API ========== /** * 调整 Island 窗口大小(切换模式) * @param mode Island 模式: "FLOAT" | "POPUP" | "SIDEBAR" | "FULLSCREEN" */ islandResizeWindow: (mode: string) => { ipcRenderer.send("island:resize-window", mode); }, /** * 调整 SIDEBAR 模式窗口大小(多栏展开/收起) * @param columnCount 栏数: 1 | 2 | 3 */ islandResizeSidebar: (columnCount: number) => { ipcRenderer.send("island:resize-sidebar", columnCount); }, /** * 显示 Island 窗口 */ islandShow: () => { ipcRenderer.send("island:show"); }, /** * 隐藏 Island 窗口 */ islandHide: () => { ipcRenderer.send("island:hide"); }, /** * 切换 Island 窗口显示/隐藏 */ islandToggle: () => { ipcRenderer.send("island:toggle"); }, /** * Island 窗口拖拽开始(自定义拖拽,仅垂直方向) * @param mouseY 鼠标屏幕 Y 坐标 */ islandDragStart: (mouseY: number) => { ipcRenderer.send("island:drag-start", mouseY); }, /** * Island 窗口拖拽移动(自定义拖拽,仅垂直方向) * @param mouseY 鼠标屏幕 Y 坐标 */ islandDragMove: (mouseY: number) => { ipcRenderer.send("island:drag-move", mouseY); }, /** * Island 窗口拖拽结束(自定义拖拽) */ islandDragEnd: () => { ipcRenderer.send("island:drag-end"); }, /** * 设置 Island SIDEBAR 模式的固定状态 * @param isPinned true = 固定(始终在顶部),false = 非固定(正常窗口行为) */ islandSetPinned: (isPinned: boolean) => { ipcRenderer.send("island:set-pinned", isPinned); }, /** * 监听 Island 窗口位置更新(拖拽时实时更新) * @param callback 回调函数,接收位置数据 */ onIslandPositionUpdate: (callback: (data: { y: number; screenHeight: number }) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { y: number; screenHeight: number }) => callback(data); ipcRenderer.on('island:position-update', listener); return () => { ipcRenderer.removeListener('island:position-update', listener); }; }, /** * 监听 Island 窗口锚点更新(模式切换时更新) * @param callback 回调函数,接收锚点数据 */ onIslandAnchorUpdate: (callback: (data: { anchor: 'top' | 'bottom' | null; y: number }) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { anchor: 'top' | 'bottom' | null; y: number }) => callback(data); ipcRenderer.on('island:anchor-update', listener); return () => { ipcRenderer.removeListener('island:anchor-update', listener); }; }, }); ================================================ FILE: free-todo-frontend/electron/process-manager.ts ================================================ /** * 进程管理基类 * 抽象前端/后端服务器的共同逻辑 */ import type { ChildProcess } from "node:child_process"; import http from "node:http"; import { TIMEOUT_CONFIG } from "./config"; import { logger } from "./logger"; /** * 服务器配置接口 */ export interface ServerConfig { /** 服务器名称(用于日志) */ name: string; /** 健康检查端点路径(如 "/" 或 "/health") */ healthEndpoint: string; /** 健康检查间隔(毫秒) */ healthCheckInterval: number; /** 等待服务就绪的超时时间(毫秒) */ readyTimeout: number; /** 健康检查接受的状态码范围 */ acceptedStatusCodes?: { min: number; max: number }; } /** * 进程管理器抽象基类 * 提供子进程生命周期管理、健康检查等通用功能 */ export abstract class ProcessManager { /** 子进程实例 */ protected process: ChildProcess | null = null; /** 健康检查定时器 */ protected healthCheckTimer: NodeJS.Timeout | null = null; /** 实际使用的端口 */ protected port: number; /** 服务器配置 */ protected readonly config: ServerConfig; /** 标准输出缓冲区 */ protected stdoutBuffer = ""; /** 标准错误输出缓冲区 */ protected stderrBuffer = ""; /** 标记是否正在主动停止(用于区分正常关闭和意外退出) */ protected isStopping = false; constructor(config: ServerConfig, defaultPort: number) { this.config = config; this.port = defaultPort; } /** * 启动服务器(由子类实现) */ abstract start(options?: { waitForReady?: boolean }): Promise; /** * 获取服务器 URL */ getUrl(): string { return `http://localhost:${this.port}`; } /** * 获取当前端口 */ getPort(): number { return this.port; } /** * 检查进程是否正在运行 */ isRunning(): boolean { return this.process !== null && !this.process.killed; } /** * 停止服务器 * @param waitForExit 是否等待进程退出(默认 false) * @returns Promise,如果 waitForExit 为 true,则等待进程退出后 resolve */ stop(waitForExit = false): Promise | void { this.isStopping = true; this.stopHealthCheck(); if (this.process) { logger.info(`Stopping ${this.config.name}...`); const proc = this.process; proc.kill("SIGTERM"); if (waitForExit) { return new Promise((resolve) => { const timeout = setTimeout(() => { logger.warn(`${this.config.name} did not exit within 3 seconds, forcing exit...`); if (proc && !proc.killed) { try { proc.kill("SIGKILL"); } catch (err) { logger.warn(`Failed to kill ${this.config.name}: ${err instanceof Error ? err.message : String(err)}`); } } this.process = null; resolve(); }, 3000); proc.once("exit", () => { clearTimeout(timeout); this.process = null; logger.info(`${this.config.name} exited`); resolve(); }); }); } else { this.process = null; } } } /** * 检查是否正在主动停止 */ isIntentionallyStopping(): boolean { return this.isStopping; } /** * 等待服务器就绪 * @param url 服务器 URL * @param timeout 超时时间(毫秒) */ protected waitForReady(url: string, timeout: number): Promise { return new Promise((resolve, reject) => { const startTime = Date.now(); const { acceptedStatusCodes } = this.config; const minStatus = acceptedStatusCodes?.min ?? 200; const maxStatus = acceptedStatusCodes?.max ?? 400; const check = () => { const checkUrl = this.config.healthEndpoint ? `${url}${this.config.healthEndpoint}` : url; http .get(checkUrl, (res) => { const statusCode = res.statusCode ?? 0; if (statusCode >= minStatus && statusCode < maxStatus) { logger.info( `${this.config.name} health check passed: ${statusCode}`, ); resolve(); } else { retry(); } }) .on("error", (err) => { const elapsed = Date.now() - startTime; // 每 10 秒记录一次 if (elapsed % 10000 < TIMEOUT_CONFIG.healthCheckRetry) { logger.info( `${this.config.name} health check failed (${elapsed}ms elapsed): ${err.message}`, ); } retry(); }) .setTimeout(TIMEOUT_CONFIG.healthCheck, () => { retry(); }); }; const retry = () => { if (Date.now() - startTime >= timeout) { reject( new Error( `${this.config.name} did not start within ${timeout}ms`, ), ); } else { setTimeout(check, TIMEOUT_CONFIG.healthCheckRetry); } }; check(); }); } /** * 启动定期健康检查 */ protected startHealthCheck(): void { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); } const url = this.getUrl(); const { acceptedStatusCodes } = this.config; const minStatus = acceptedStatusCodes?.min ?? 200; const maxStatus = acceptedStatusCodes?.max ?? 400; this.healthCheckTimer = setInterval(() => { if (!this.isRunning()) { logger.warn(`${this.config.name} process is not running`); return; } const checkUrl = this.config.healthEndpoint ? `${url}${this.config.healthEndpoint}` : url; http .get(checkUrl, (res) => { const statusCode = res.statusCode ?? 0; if (statusCode < minStatus || statusCode >= maxStatus) { logger.warn( `${this.config.name} returned status ${statusCode}`, ); } }) .on("error", (error) => { logger.warn( `${this.config.name} health check failed: ${error.message}`, ); if (this.isRunning()) { logger.warn( `${this.config.name} process exists but not responding`, ); } }) .setTimeout(TIMEOUT_CONFIG.healthCheck, () => { logger.warn(`${this.config.name} health check timeout`); }); }, this.config.healthCheckInterval); } /** * 停止健康检查 */ protected stopHealthCheck(): void { if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null; } } /** * 设置子进程的输出监听器 * @param proc 子进程实例 */ protected setupProcessOutputListeners(proc: ChildProcess): void { if (proc.stdout) { proc.stdout.setEncoding("utf8"); proc.stdout.on("data", (data) => { const output = String(data); this.stdoutBuffer += output; logger.stdout(this.config.name, output); }); proc.stdout.on("end", () => { logger.info(`${this.config.name} stdout stream ended`); }); proc.stdout.on("error", (err) => { logger.error(`${this.config.name} stdout stream error: ${err.message}`); }); } if (proc.stderr) { proc.stderr.setEncoding("utf8"); proc.stderr.on("data", (data) => { const output = String(data); this.stderrBuffer += output; logger.stderr(this.config.name, output); }); proc.stderr.on("end", () => { logger.info(`${this.config.name} stderr stream ended`); }); proc.stderr.on("error", (err) => { logger.error(`${this.config.name} stderr stream error: ${err.message}`); }); } } /** * 获取输出缓冲区内容(用于错误报告) */ getOutputBuffers(): { stdout: string; stderr: string } { return { stdout: this.stdoutBuffer, stderr: this.stderrBuffer, }; } /** * 清空输出缓冲区 */ clearOutputBuffers(): void { this.stdoutBuffer = ""; this.stderrBuffer = ""; } } ================================================ FILE: free-todo-frontend/electron/python-runtime-command.ts ================================================ /** * Shared command runner for Python runtime bootstrap */ import { spawn } from "node:child_process"; import { clearActiveProcess, isCancelled, onCancel, setActiveProcess, } from "./bootstrap-control"; export type CommandResult = { code: number | null; stdout: string; stderr: string; }; export type CommandOptions = { cwd?: string; env?: NodeJS.ProcessEnv; windowsHide?: boolean; onStdout?: (chunk: string) => void; onStderr?: (chunk: string) => void; }; export async function runCommand( command: string, args: string[], options: CommandOptions = {}, ): Promise { if (isCancelled()) { return { code: 1, stdout: "", stderr: "Installation cancelled" }; } return new Promise((resolve) => { const child = spawn(command, args, { cwd: options.cwd, env: options.env, windowsHide: options.windowsHide ?? true, }); setActiveProcess(child); const unsubscribe = onCancel(() => { try { child.kill(); } catch { // ignore kill errors } }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (data) => { stdout += data.toString(); options.onStdout?.(data.toString()); }); child.stderr?.on("data", (data) => { stderr += data.toString(); options.onStderr?.(data.toString()); }); child.on("close", (code) => { unsubscribe(); clearActiveProcess(child); resolve({ code, stdout, stderr }); }); child.on("error", (error) => { unsubscribe(); clearActiveProcess(child); resolve({ code: 1, stdout: "", stderr: error.message }); }); }); } ================================================ FILE: free-todo-frontend/electron/python-runtime-env.ts ================================================ /** * Python runtime environment helpers (mirrors, region detection) */ import { app } from "electron"; import { emitLog } from "./bootstrap-status"; import { runCommand } from "./python-runtime-command"; const PIP_INDEX_CN = "https://pypi.tuna.tsinghua.edu.cn/simple"; const PIP_INDEX_GLOBAL = "https://pypi.org/simple"; let pipMirrorLogged = false; let condaMirrorConfigured = false; function isMainlandChina(): boolean { const override = process.env.FREETODO_REGION?.toLowerCase(); if (override === "cn") { return true; } if (override === "global" || override === "intl") { return false; } const locale = app.getLocale?.() ?? ""; const languages = app.getPreferredSystemLanguages?.() ?? []; const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; if (locale.toLowerCase().startsWith("zh-cn")) { return true; } if (languages.some((lang) => lang.toLowerCase().startsWith("zh-cn"))) { return true; } return [ "Asia/Shanghai", "Asia/Chongqing", "Asia/Harbin", "Asia/Urumqi", "Asia/Beijing", ].includes(timeZone); } export function getPipEnv(): NodeJS.ProcessEnv { const useCn = isMainlandChina(); if (!pipMirrorLogged) { const label = useCn ? "清华源" : "PyPI 官方源"; emitLog(`Pip index selected: ${label}`); pipMirrorLogged = true; } const baseEnv: NodeJS.ProcessEnv = { ...process.env, PIP_DISABLE_PIP_VERSION_CHECK: "1", PIP_NO_INPUT: "1", }; if (useCn) { return { ...baseEnv, PIP_INDEX_URL: PIP_INDEX_CN, PIP_EXTRA_INDEX_URL: PIP_INDEX_GLOBAL, }; } return { ...baseEnv, PIP_INDEX_URL: PIP_INDEX_GLOBAL, }; } export function getUvEnv(): NodeJS.ProcessEnv { const pipEnv = getPipEnv(); const useCn = isMainlandChina(); if (useCn) { return { ...pipEnv, UV_INDEX_URL: PIP_INDEX_CN, UV_EXTRA_INDEX_URL: PIP_INDEX_GLOBAL, }; } return { ...pipEnv, UV_INDEX_URL: PIP_INDEX_GLOBAL, }; } export async function configureCondaMirror(): Promise { if (condaMirrorConfigured || !isMainlandChina()) { return; } const condaCheck = await runCommand("conda", ["--version"]); if (condaCheck.code !== 0) { emitLog("Conda not found; skipping mirror config."); return; } const commands: string[][] = [ ["config", "--set", "show_channel_urls", "yes"], [ "config", "--add", "channels", "https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/", ], [ "config", "--add", "channels", "https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/", ], [ "config", "--add", "channels", "https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/r/", ], ]; for (const args of commands) { const result = await runCommand("conda", args); if (result.code !== 0) { emitLog(`Conda mirror config failed: ${result.stderr || result.stdout}`); return; } } condaMirrorConfigured = true; emitLog("Conda mirror configured to Tsinghua."); } ================================================ FILE: free-todo-frontend/electron/python-runtime-installer.ts ================================================ /** * Python 3.12 installer helpers (download + system install) */ import fs from "node:fs"; import https from "node:https"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { app } from "electron"; import { onCancel } from "./bootstrap-control"; import { emitLog, emitStatus } from "./bootstrap-status"; import { logger } from "./logger"; import { runCommand } from "./python-runtime-command"; const PYTHON_VERSION_FALLBACK = "3.12.9"; const PYTHON_DOWNLOAD_BASE = "https://www.python.org/ftp/python"; const PYTHON_RELEASES_API = "https://www.python.org/api/v2/downloads/release/?is_published=1"; type PythonRelease = { version?: string; name?: string; }; async function fetchJson(url: string): Promise { return await new Promise((resolve, reject) => { const request = https.get(url, (response) => { const status = response.statusCode ?? 0; if (status !== 200) { reject(new Error(`Request failed (${status}) from ${url}`)); return; } const chunks: Array = []; response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); response.on("end", () => { try { const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as T; resolve(json); } catch (error) { reject(error); } }); }); const unsubscribe = onCancel(() => { request.destroy(new Error("Installation cancelled")); }); request.on("error", (error) => { unsubscribe(); reject(error); }); }); } function compareSemver(a: string, b: string): number { const aParts = a.split(".").map((part) => Number(part)); const bParts = b.split(".").map((part) => Number(part)); const length = Math.max(aParts.length, bParts.length); for (let index = 0; index < length; index += 1) { const aValue = aParts[index] ?? 0; const bValue = bParts[index] ?? 0; if (aValue !== bValue) { return aValue - bValue; } } return 0; } async function getLatestPython312Version(): Promise { try { const releases = await fetchJson(PYTHON_RELEASES_API); const versions = releases .map((release) => release.version ?? release.name ?? "") .filter((version) => version.startsWith("3.12.")); if (versions.length === 0) { logger.warn("No Python 3.12 release found, using fallback."); return PYTHON_VERSION_FALLBACK; } const sorted = versions.sort(compareSemver); const latest = sorted[sorted.length - 1] ?? PYTHON_VERSION_FALLBACK; logger.info(`Resolved Python 3.12 version: ${latest}`); return latest; } catch (error) { logger.warn(`Failed to resolve latest Python 3.12: ${String(error)}`); return PYTHON_VERSION_FALLBACK; } } async function downloadFile( url: string, destination: string, redirectsLeft = 5, ): Promise { await new Promise((resolve, reject) => { const request = https.get(url, (response) => { const status = response.statusCode ?? 0; const location = response.headers.location; if (status >= 300 && status < 400 && location && redirectsLeft > 0) { response.resume(); const redirectUrl = new URL(location, url).toString(); downloadFile(redirectUrl, destination, redirectsLeft - 1) .then(resolve) .catch(reject); return; } if (status !== 200) { reject(new Error(`Download failed (${status}) from ${url}`)); return; } const fileStream = fs.createWriteStream(destination); pipeline(response, fileStream) .then(() => { unsubscribe(); resolve(); }) .catch((error) => { unsubscribe(); reject(error); }); }); const unsubscribe = onCancel(() => { request.destroy(new Error("Installation cancelled")); }); request.on("error", (error) => { unsubscribe(); reject(error); }); }); } async function installPythonWindows(version: string): Promise { emitStatus({ message: "安装 Python 3.12", progress: 20 }); const wingetCheck = await runCommand("winget", ["--version"]); if (wingetCheck.code === 0) { logger.info("Installing Python via winget..."); emitLog("Using winget to install Python 3.12..."); const install = await runCommand( "winget", [ "install", "--id", "Python.Python.3.12", "--exact", "--silent", "--accept-source-agreements", "--accept-package-agreements", "--scope", "user", ], { onStdout: emitLog, onStderr: emitLog }, ); if (install.code === 0) { emitLog("Winget install completed."); return; } logger.warn(`Winget install failed: ${install.stderr || install.stdout}`); emitLog(`Winget install failed: ${install.stderr || install.stdout}`); } const arch = process.arch === "arm64" ? "arm64" : "amd64"; const fileName = `python-${version}-${arch}.exe`; const url = `${PYTHON_DOWNLOAD_BASE}/${version}/${fileName}`; const tempDir = path.join(app.getPath("temp"), "freetodo-python"); fs.mkdirSync(tempDir, { recursive: true }); const installerPath = path.join(tempDir, fileName); logger.info(`Downloading Python installer from ${url}`); emitStatus({ message: "下载 Python 安装包", progress: 25, detail: url }); await downloadFile(url, installerPath); logger.info("Running Python installer..."); emitStatus({ message: "运行 Python 安装程序", progress: 35 }); const install = await runCommand(installerPath, [ "/quiet", "InstallAllUsers=0", "PrependPath=1", "Include_test=0", ]); if (install.code !== 0) { throw new Error(`Python installer failed: ${install.stderr || install.stdout}`); } } async function installPythonMac(version: string): Promise { emitStatus({ message: "安装 Python 3.12", progress: 20 }); const fileName = `python-${version}-macos11.pkg`; const url = `${PYTHON_DOWNLOAD_BASE}/${version}/${fileName}`; const tempDir = path.join(app.getPath("temp"), "freetodo-python"); fs.mkdirSync(tempDir, { recursive: true }); const pkgPath = path.join(tempDir, fileName); logger.info(`Downloading Python installer from ${url}`); emitStatus({ message: "下载 Python 安装包", progress: 25, detail: url }); await downloadFile(url, pkgPath); const installerCommand = `installer -pkg "${pkgPath}" -target /`; const script = `do shell script "${installerCommand.replace(/"/g, '\\"')}" with administrator privileges`; logger.info("Running Python installer with admin privileges..."); emitStatus({ message: "运行 Python 安装程序", progress: 35 }); const install = await runCommand("osascript", ["-e", script]); if (install.code !== 0) { throw new Error(`Python installer failed: ${install.stderr || install.stdout}`); } } async function installPythonLinux(): Promise { emitStatus({ message: "安装 Python 3.12", progress: 20 }); const installers: Array<{ command: string; args: string[] }> = [ { command: "apt-get", args: ["install", "-y", "python3.12", "python3.12-venv"] }, { command: "dnf", args: ["install", "-y", "python3.12"] }, { command: "zypper", args: ["--non-interactive", "install", "python312"] }, ]; for (const installer of installers) { emitLog(`Attempting ${installer.command} install...`); const result = await runCommand("pkexec", [ installer.command, ...installer.args, ]); if (result.code === 0) { emitLog(`${installer.command} install completed.`); return; } logger.warn(`Linux installer failed: ${result.stderr || result.stdout}`); emitLog(`Linux installer failed: ${result.stderr || result.stdout}`); } throw new Error("Automatic Python install failed on Linux."); } export async function installPython312(): Promise { const version = await getLatestPython312Version(); if (process.platform === "win32") { await installPythonWindows(version); return; } if (process.platform === "darwin") { await installPythonMac(version); return; } if (process.platform === "linux") { await installPythonLinux(); return; } throw new Error("Unsupported platform for Python install."); } ================================================ FILE: free-todo-frontend/electron/python-runtime.ts ================================================ /** * Python runtime bootstrapper * Ensures Python 3.12 and backend dependencies are installed before starting the backend. */ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { dialog } from "electron"; import { isCancelled } from "./bootstrap-control"; import { emitLog, emitStatus } from "./bootstrap-status"; import { logger } from "./logger"; import { runCommand } from "./python-runtime-command"; import { configureCondaMirror, getPipEnv, getUvEnv } from "./python-runtime-env"; import { installPython312 } from "./python-runtime-installer"; const REQUIRED_PYTHON_MAJOR = 3; const REQUIRED_PYTHON_MINOR = 12; const PYTHON_VERSION_SHORT = `${REQUIRED_PYTHON_MAJOR}.${REQUIRED_PYTHON_MINOR}`; const DEP_MARKER_FILE = ".freetodo-deps.json"; const RUNTIME_MANIFEST_FILE = ".freetodo-runtime.json"; let preferredPythonPath: string | null = null; type PythonInfo = { executable: string; version: string; prefix: string; isConda: boolean; }; export function setPreferredPythonPath(value: string | null): void { preferredPythonPath = value; } function getVenvPythonPath(venvDir: string): string { if (process.platform === "win32") { return path.join(venvDir, "Scripts", "python.exe"); } return path.join(venvDir, "bin", "python3"); } function getVenvUvPath(venvDir: string): string { if (process.platform === "win32") { return path.join(venvDir, "Scripts", "uv.exe"); } return path.join(venvDir, "bin", "uv"); } function readFileHash(filePath: string): string { const contents = fs.readFileSync(filePath); return crypto.createHash("sha256").update(contents).digest("hex"); } function readDepsMarker(venvDir: string): { requirementsHash?: string } | null { const markerPath = path.join(venvDir, DEP_MARKER_FILE); if (!fs.existsSync(markerPath)) { return null; } try { const raw = fs.readFileSync(markerPath, "utf8"); return JSON.parse(raw) as { requirementsHash?: string }; } catch { return null; } } type RuntimeManifest = { pythonPath: string; venvPath?: string; requirementsHash: string; createdAt: string; }; function readRuntimeManifest(runtimeRoot: string): RuntimeManifest | null { const manifestPath = path.join(runtimeRoot, RUNTIME_MANIFEST_FILE); if (!fs.existsSync(manifestPath)) { return null; } try { const raw = fs.readFileSync(manifestPath, "utf8"); return JSON.parse(raw) as RuntimeManifest; } catch { return null; } } function writeRuntimeManifest(runtimeRoot: string, manifest: RuntimeManifest): void { const manifestPath = path.join(runtimeRoot, RUNTIME_MANIFEST_FILE); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); } function writeDepsMarker(venvDir: string, requirementsHash: string): void { const markerPath = path.join(venvDir, DEP_MARKER_FILE); const payload = { requirementsHash, createdAt: new Date().toISOString(), }; fs.writeFileSync(markerPath, JSON.stringify(payload, null, 2)); } function isRequiredPython(version: string): boolean { return version.trim() === PYTHON_VERSION_SHORT; } function normalizeOutput(value: string): string { return value.replace(/\r/g, "").trim(); } function assertNotCancelled(): void { if (isCancelled()) { throw new Error("Installation cancelled"); } } function getVersionFromOutput(output: string): PythonInfo | null { const line = normalizeOutput(output).split("\n")[0]; if (!line) { return null; } try { const parsed = JSON.parse(line) as { version?: string; executable?: string; prefix?: string; is_conda?: boolean; }; if (!parsed.version || !parsed.executable || !parsed.prefix) { return null; } return { version: parsed.version.trim(), executable: parsed.executable.trim(), prefix: parsed.prefix.trim(), isConda: Boolean(parsed.is_conda), }; } catch { return null; } } async function getPythonInfo(command: string, args: string[]): Promise { const result = await runCommand(command, [ ...args, "-c", "import json, os, sys; prefix=sys.prefix; is_conda=os.path.exists(os.path.join(prefix, 'conda-meta')); print(json.dumps({'version': f'{sys.version_info[0]}.{sys.version_info[1]}', 'executable': sys.executable, 'prefix': prefix, 'is_conda': is_conda}))", ]); if (result.code !== 0) { return null; } return getVersionFromOutput(result.stdout); } export async function validatePythonPath(pythonPath: string): Promise { if (!pythonPath) { return null; } return getPythonInfo(pythonPath, []); } export function isRuntimePrepared( runtimeRoot: string, venvDir: string, requirementsPath: string, ): boolean { if (!fs.existsSync(requirementsPath)) { return false; } const manifest = readRuntimeManifest(runtimeRoot); if (!manifest) { return false; } const requirementsHash = readFileHash(requirementsPath); if (manifest.requirementsHash !== requirementsHash) { return false; } if (manifest.venvPath) { const venvPython = getVenvPythonPath(venvDir); return fs.existsSync(venvPython); } return fs.existsSync(manifest.pythonPath); } async function findInstalledPython312(): Promise { const candidates: Array<{ command: string; args: string[] }> = []; if (process.platform === "win32") { candidates.push({ command: "py", args: ["-3.12"] }); candidates.push({ command: "python3.12", args: [] }); candidates.push({ command: "python", args: [] }); candidates.push({ command: "python3", args: [] }); } else { candidates.push({ command: "python3.12", args: [] }); candidates.push({ command: "python3", args: [] }); candidates.push({ command: "python", args: [] }); } for (const candidate of candidates) { const info = await getPythonInfo(candidate.command, candidate.args); if (!info || !isRequiredPython(info.version)) { continue; } if (fs.existsSync(info.executable)) { return info; } } const fallbackPaths: string[] = []; if (process.platform === "win32") { const localAppData = process.env.LOCALAPPDATA ?? ""; const programFiles = process.env.ProgramFiles ?? ""; const programFilesX86 = process.env["ProgramFiles(x86)"] ?? ""; fallbackPaths.push( path.join(localAppData, "Programs", "Python", "Python312", "python.exe"), path.join(programFiles, "Python312", "python.exe"), path.join(programFilesX86, "Python312", "python.exe"), ); } else if (process.platform === "darwin") { fallbackPaths.push( "/usr/local/bin/python3.12", "/opt/homebrew/bin/python3.12", "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12", ); } else { fallbackPaths.push("/usr/bin/python3.12", "/usr/local/bin/python3.12"); } for (const candidatePath of fallbackPaths) { if (!candidatePath || !fs.existsSync(candidatePath)) { continue; } const info = await getPythonInfo(candidatePath, []); if (info && isRequiredPython(info.version)) { return info; } } return null; } async function ensurePython312Installed(): Promise { emitStatus({ message: "检查 Python 3.12", progress: 10 }); if (preferredPythonPath) { const preferredInfo = await validatePythonPath(preferredPythonPath); if (preferredInfo && isRequiredPython(preferredInfo.version)) { emitLog(`Using selected Python: ${preferredInfo.executable}`); return preferredInfo; } emitLog("Selected Python is not compatible with 3.12."); } const existing = await findInstalledPython312(); if (existing) { emitLog(`Found Python 3.12 at ${existing.executable}`); return existing; } while (true) { const response = await dialog.showMessageBox({ type: "info", buttons: ["选择已有 Python", "自动安装", "取消"], defaultId: 1, cancelId: 2, message: "FreeTodo 需要 Python 3.12 才能运行本地后端。", detail: "你可以选择已有的 Python 3.12 环境,或者让程序自动安装。自动安装需要联网,可能会花费几分钟。", }); if (response.response === 0) { const dialogOptions: Electron.OpenDialogOptions = { properties: ["openFile"], title: "选择 Python 3.12 可执行文件", }; if (process.platform === "win32") { dialogOptions.filters = [{ name: "Python", extensions: ["exe"] }]; } const selected = await dialog.showOpenDialog(dialogOptions); if (selected.canceled || selected.filePaths.length === 0) { continue; } const chosenPath = selected.filePaths[0]; const info = await validatePythonPath(chosenPath); if (!info || !isRequiredPython(info.version)) { dialog.showErrorBox("Python 版本不匹配", "请选择 Python 3.12 的可执行文件。"); continue; } preferredPythonPath = info.executable; emitStatus({ pythonPath: info.executable }); return info; } if (response.response === 1) { break; } throw new Error("Python 3.12 installation cancelled by user."); } await installPython312(); const installed = await findInstalledPython312(); if (!installed) { throw new Error("Python 3.12 installation completed but was not detected."); } return installed; } async function ensureVenv( systemPythonPath: string, venvDir: string, ): Promise { if (fs.existsSync(getVenvPythonPath(venvDir))) { return; } emitStatus({ message: "创建 Python 虚拟环境", progress: 45 }); emitLog(`Creating virtual environment at: ${venvDir}`); fs.mkdirSync(venvDir, { recursive: true }); const uvCheck = await runCommand("uv", ["--version"]); const useUv = uvCheck.code === 0; const result = useUv ? await runCommand("uv", ["venv", venvDir, "--python", systemPythonPath], { env: getUvEnv(), }) : await runCommand(systemPythonPath, ["-m", "venv", venvDir]); if (result.code !== 0) { throw new Error( `Failed to create venv: ${result.stderr || result.stdout}`, ); } } async function ensureUvInVenv( venvPython: string, venvDir: string, ): Promise { const uvPath = getVenvUvPath(venvDir); if (fs.existsSync(uvPath)) { return uvPath; } emitStatus({ message: "安装 uv", progress: 52 }); emitLog("Installing uv into virtual environment..."); const env = getPipEnv(); const install = await runCommand( venvPython, ["-m", "pip", "install", "--upgrade", "uv"], { env, onStdout: emitLog, onStderr: emitLog }, ); if (install.code !== 0) { throw new Error(`Failed to install uv: ${install.stderr || install.stdout}`); } if (!fs.existsSync(uvPath)) { throw new Error("uv installed but executable was not found in venv."); } return uvPath; } async function ensureDependencies( venvPython: string, venvDir: string, requirementsPath: string, ): Promise { if (!fs.existsSync(requirementsPath)) { throw new Error(`Requirements file not found: ${requirementsPath}`); } emitLog(`Using requirements: ${requirementsPath}`); const requirementsHash = readFileHash(requirementsPath); const marker = readDepsMarker(venvDir); if (marker?.requirementsHash === requirementsHash) { return; } await dialog.showMessageBox({ type: "info", buttons: ["Continue"], message: "Installing backend dependencies", detail: "This is the first launch. FreeTodo will now download and install Python dependencies. It may take several minutes depending on your network.", }); assertNotCancelled(); const uvPath = await ensureUvInVenv(venvPython, venvDir); emitStatus({ message: "安装后端依赖", progress: 60 }); const env = getUvEnv(); const install = await runCommand( uvPath, ["pip", "install", "-r", requirementsPath, "--python", venvPython], { env, onStdout: emitLog, onStderr: emitLog }, ); if (install.code !== 0) { throw new Error(`Failed to install dependencies: ${install.stderr || install.stdout}`); } writeDepsMarker(venvDir, requirementsHash); } async function ensureVenvPythonVersion(venvPython: string): Promise { const info = await getPythonInfo(venvPython, []); return !!info && isRequiredPython(info.version); } export async function ensurePythonRuntime( venvDir: string, requirementsPath: string, ): Promise { emitStatus({ message: "准备 Python 运行时", progress: 5 }); const venvPython = getVenvPythonPath(venvDir); emitStatus({ venvPath: venvDir }); assertNotCancelled(); const runtimeRoot = path.dirname(venvDir); if (fs.existsSync(venvPython)) { const versionOk = await ensureVenvPythonVersion(venvPython); if (versionOk) { emitStatus({ message: "检查后端依赖", progress: 55 }); await ensureDependencies(venvPython, venvDir, requirementsPath); emitStatus({ message: "Python 运行时就绪", progress: 70 }); writeRuntimeManifest(runtimeRoot, { pythonPath: venvPython, venvPath: venvDir, requirementsHash: readFileHash(requirementsPath), createdAt: new Date().toISOString(), }); return venvPython; } logger.warn("Existing venv does not match Python 3.12, recreating."); emitLog("Existing venv does not match Python 3.12, recreating."); } const systemPython = await ensurePython312Installed(); emitStatus({ pythonPath: systemPython.executable }); assertNotCancelled(); if (systemPython.isConda) { emitLog("Detected conda environment."); await configureCondaMirror(); } await ensureVenv(systemPython.executable, venvDir); emitStatus({ pythonPath: venvPython }); if (!fs.existsSync(venvPython)) { throw new Error("Virtual environment was created but python executable is missing."); } await ensureDependencies(venvPython, venvDir, requirementsPath); emitStatus({ message: "Python 运行时就绪", progress: 70 }); writeRuntimeManifest(runtimeRoot, { pythonPath: venvPython, venvPath: venvDir, requirementsHash: readFileHash(requirementsPath), createdAt: new Date().toISOString(), }); return venvPython; } export { getVenvPythonPath }; ================================================ FILE: free-todo-frontend/electron/runtime-paths.ts ================================================ /** * Runtime path helpers */ import fs from "node:fs"; import path from "node:path"; import { app } from "electron"; import { emitLog, emitStatus } from "./bootstrap-status"; import { PROCESS_CONFIG } from "./config"; export function getInstallRoot(): string { if (!app.isPackaged) { return path.resolve(__dirname, "../.."); } if (process.platform === "darwin") { return path.resolve(process.execPath, "..", "..", ".."); } return path.dirname(process.execPath); } function canWrite(dir: string): boolean { try { fs.mkdirSync(dir, { recursive: true }); const testFile = path.join(dir, ".freetodo-write-test"); fs.writeFileSync(testFile, "ok"); fs.unlinkSync(testFile); return true; } catch { return false; } } export function resolveRuntimeRoot(): string { const envOverride = process.env.FREETODO_RUNTIME_DIR; if (envOverride) { fs.mkdirSync(envOverride, { recursive: true }); return envOverride; } const installRoot = getInstallRoot(); const preferred = path.join(installRoot, PROCESS_CONFIG.backendRuntimeDir); if (canWrite(preferred)) { return preferred; } emitLog(`Install directory not writable: ${preferred}`); const fallback = path.join(app.getPath("userData"), PROCESS_CONFIG.backendRuntimeDir); fs.mkdirSync(fallback, { recursive: true }); emitStatus({ message: "安装目录不可写,已切换运行时目录", detail: fallback, venvPath: fallback, }); return fallback; } export function resolveVenvDir(): string { const runtimeRoot = resolveRuntimeRoot(); const venvDir = path.join(runtimeRoot, PROCESS_CONFIG.backendVenvDir); return venvDir; } ================================================ FILE: free-todo-frontend/electron/tray-manager.ts ================================================ /** * System Tray / Menu Bar Manager * Manages the application tray icon and context menu * Provides extensible menu structure for future features */ import path from "node:path"; import { app, Menu, type MenuItemConstructorOptions, nativeImage, Tray } from "electron"; import type { IslandWindowManager } from "./island-window-manager"; import { logger } from "./logger"; /** * TrayManager class * Manages system tray icon and menu for cross-platform support */ export class TrayManager { /** Tray instance */ private tray: Tray | null = null; /** Island window manager reference */ private islandWindowManager: IslandWindowManager; /** Context menu instance */ private contextMenu: Menu | null = null; /** * Constructor * @param islandWindowManager Island window manager instance */ constructor(islandWindowManager: IslandWindowManager) { this.islandWindowManager = islandWindowManager; // Set up visibility change callback to update tray icon this.islandWindowManager.setVisibilityChangeCallback((visible) => { this.onIslandVisibilityChange(visible); }); } /** * Create and initialize the tray icon */ create(): void { if (this.tray) { logger.warn("Tray already exists"); return; } const iconPath = this.getTrayIconPath(); if (!iconPath) { logger.error("Failed to get tray icon path"); return; } try { // Create tray icon const icon = nativeImage.createFromPath(iconPath); // Resize for proper display (16x16 on macOS, 16x16 on Windows) const resizedIcon = icon.resize({ width: 16, height: 16 }); this.tray = new Tray(resizedIcon); // Set tooltip this.tray.setToolTip("Free Todo - Dynamic Island"); // Build context menu this.buildContextMenu(); // Set up event handlers this.setupEventHandlers(); logger.info("Tray icon created successfully"); } catch (error) { logger.error(`Failed to create tray icon: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get the appropriate tray icon path based on platform */ private getTrayIconPath(): string | null { try { // Use the Free Todo icon as tray icon from public/free-todo-logos if (app.isPackaged) { // Production: use packaged public folder const resourcesPath = process.resourcesPath; return path.join(resourcesPath, "standalone", "public", "free-todo-logos", "free_todo_icon_4.png"); } // Development: use public folder icons return path.join(__dirname, "..", "public", "free-todo-logos", "free_todo_icon_4.png"); } catch (error) { logger.error(`Error getting tray icon path: ${error instanceof Error ? error.message : String(error)}`); return null; } } /** * Build the context menu */ private buildContextMenu(): void { const menuTemplate: MenuItemConstructorOptions[] = [ { label: "Show/Hide Island", accelerator: "CommandOrControl+Shift+I", click: () => this.toggleIsland(), }, { type: "separator" }, { label: "Recording", submenu: [ { label: "Start Recording", enabled: false, // Future feature click: () => this.startRecording(), }, { label: "Stop Recording", enabled: false, // Future feature click: () => this.stopRecording(), }, ], }, { label: "Screenshots", submenu: [ { label: "Take Screenshot", enabled: false, // Future feature click: () => this.takeScreenshot(), }, { label: "View Recent...", enabled: false, // Future feature click: () => this.viewScreenshots(), }, ], }, { type: "separator" }, { label: "Preferences...", click: () => this.openPreferences(), }, { type: "separator" }, { label: "Quit Free Todo", role: "quit", }, ]; this.contextMenu = Menu.buildFromTemplate(menuTemplate); this.tray?.setContextMenu(this.contextMenu); } /** * Setup tray event handlers */ private setupEventHandlers(): void { if (!this.tray) return; // Left-click: toggle island visibility this.tray.on("click", () => { this.toggleIsland(); }); // Right-click: show context menu (handled automatically on Windows) // On macOS, we need to handle it explicitly if (process.platform === "darwin") { this.tray.on("right-click", () => { if (this.contextMenu && this.tray) { this.tray.popUpContextMenu(this.contextMenu); } }); } } /** * Toggle island window visibility */ private toggleIsland(): void { try { this.islandWindowManager.toggle(); this.updateTrayIcon(); } catch (error) { logger.error(`Failed to toggle island: ${error instanceof Error ? error.message : String(error)}`); } } /** * Update tray icon appearance based on island visibility * Future: could show different icon states */ private updateTrayIcon(): void { if (!this.tray) return; const isVisible = this.islandWindowManager.isVisible(); // Update tooltip to reflect current state this.tray.setToolTip( isVisible ? "Free Todo - Dynamic Island (Visible)" : "Free Todo - Dynamic Island (Hidden)" ); // Future: could change icon appearance here // For example, use a dimmed icon when hidden } /** * Handle island visibility change events * @param visible Current visibility state */ private onIslandVisibilityChange(visible: boolean): void { this.updateTrayIcon(); logger.info(`Tray updated: Island is now ${visible ? "visible" : "hidden"}`); } /** * Show the island window */ show(): void { this.islandWindowManager.show(); this.updateTrayIcon(); } /** * Hide the island window */ hide(): void { this.islandWindowManager.hide(); this.updateTrayIcon(); } /** * Update the context menu * Call this when menu state needs to change */ updateMenu(): void { this.buildContextMenu(); } /** * Destroy the tray icon */ destroy(): void { if (this.tray) { this.tray.destroy(); this.tray = null; logger.info("Tray icon destroyed"); } } /** * Get tray instance */ getTray(): Tray | null { return this.tray; } // ========== Future Feature Placeholders ========== /** * Start recording (future feature) */ private startRecording(): void { logger.info("Start recording - feature not yet implemented"); // TODO: Implement recording functionality } /** * Stop recording (future feature) */ private stopRecording(): void { logger.info("Stop recording - feature not yet implemented"); // TODO: Implement recording functionality } /** * Take screenshot (future feature) */ private takeScreenshot(): void { logger.info("Take screenshot - feature not yet implemented"); // TODO: Implement screenshot functionality } /** * View screenshots (future feature) */ private viewScreenshots(): void { logger.info("View screenshots - feature not yet implemented"); // TODO: Implement screenshot viewer } /** * Open preferences window (future feature) */ private openPreferences(): void { logger.info("Open preferences - feature not yet implemented"); // TODO: Implement preferences window // For now, just show the island this.show(); } } ================================================ FILE: free-todo-frontend/electron/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "module": "CommonJS", "moduleResolution": "node", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "../dist-electron", "rootDir": ".", "declaration": false, "sourceMap": true }, "include": ["./**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: free-todo-frontend/electron/window-manager.ts ================================================ /** * 窗口管理服务 * 封装 BrowserWindow 创建和事件处理 */ import http from "node:http"; import path from "node:path"; import { app, BrowserWindow, dialog } from "electron"; import { WINDOW_CONFIG, } from "./config"; import { logger } from "./logger"; /** * 窗口管理器类 * 负责主窗口的创建、管理和事件处理 */ export class WindowManager { /** 主窗口实例 */ private mainWindow: BrowserWindow | null = null; /** 保存窗口的原始位置和尺寸(用于从全屏模式恢复) */ private originalBounds: { x: number; y: number; width: number; height: number; } | null = null; /** * 获取 preload 脚本路径 */ private getPreloadPath(): string { if (app.isPackaged) { // 打包环境:preload.js 在 dist-electron 目录下(和 main.js 在同一目录) return path.join(app.getAppPath(), "dist-electron", "preload.js"); } // 开发环境:使用编译后的文件路径(dist-electron 目录) return path.join(__dirname, "preload.js"); } /** * 等待服务器就绪 * @param url 服务器 URL * @param timeout 超时时间(毫秒) */ private async waitForServer(url: string, timeout: number): Promise { return new Promise((resolve, reject) => { const startTime = Date.now(); const check = () => { http .get(url, (res) => { if (res.statusCode === 200 || res.statusCode === 304) { resolve(); } else { retry(); } }) .on("error", () => { retry(); }); }; const retry = () => { if (Date.now() - startTime >= timeout) { reject(new Error(`Server did not start within ${timeout}ms`)); } else { setTimeout(check, 500); } }; check(); }); } /** * 获取原始窗口边界 */ getOriginalBounds(): typeof this.originalBounds { return this.originalBounds; } /** * 创建主窗口 * @param serverUrl 前端服务器 URL */ create( serverUrl: string, options?: { waitForServer?: boolean; showLoading?: boolean }, ): void { const { waitForServer = true, showLoading = false } = options ?? {}; const preloadPath = this.getPreloadPath(); // 保存原始位置和尺寸(用于从全屏模式恢复) if (!this.originalBounds) { this.originalBounds = { x: 0, y: 0, width: WINDOW_CONFIG.width, height: WINDOW_CONFIG.height, }; } this.mainWindow = new BrowserWindow({ width: WINDOW_CONFIG.width, height: WINDOW_CONFIG.height, x: 0, y: 0, minWidth: WINDOW_CONFIG.minWidth, minHeight: WINDOW_CONFIG.minHeight, frame: true, transparent: false, alwaysOnTop: false, hasShadow: true, resizable: true, movable: true, skipTaskbar: false, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: preloadPath, }, show: false, // 等待内容加载完成再显示 backgroundColor: WINDOW_CONFIG.backgroundColor, }); // 监听页面加载完成,检查 preload 脚本是否正确加载 this.mainWindow.webContents.once("did-finish-load", () => { logger.info("Page finished loading, checking preload script..."); // 注入调试代码检查 electronAPI this.mainWindow?.webContents .executeJavaScript(` (function() { const hasElectronAPI = typeof window.electronAPI !== 'undefined'; const result = { hasElectronAPI, electronAPIKeys: hasElectronAPI ? Object.keys(window.electronAPI) : [], userAgent: navigator.userAgent, }; console.log('[Electron Main] Preload script check:', result); return result; })(); `) .then((result) => { logger.info( `Preload script check result: ${JSON.stringify(result, null, 2)}`, ); if (!result.hasElectronAPI) { logger.warn( "WARNING: electronAPI is not available in renderer process!", ); console.warn( "[WARN] electronAPI is not available. Check preload script loading.", ); } else { logger.info("✅ electronAPI is available in renderer process"); logger.info(`Available methods: ${result.electronAPIKeys.join(", ")}`); } }) .catch((err) => { logger.error(`Error checking preload script: ${err instanceof Error ? err.message : String(err)}`); console.error("Error checking preload script:", err); }); }); // 设置 ready-to-show 事件监听器 this.mainWindow.once("ready-to-show", () => { if (this.mainWindow) { this.mainWindow.show(); logger.info("Window is ready to show"); } }); // 拦截导航,防止加载到错误的 URL(如 DevTools URL) this.mainWindow.webContents.on("will-navigate", (event, navigationUrl) => { const parsedUrl = new URL(navigationUrl); // 只允许加载 localhost:PORT 的 URL if ( parsedUrl.hostname !== "localhost" && parsedUrl.hostname !== "127.0.0.1" ) { event.preventDefault(); logger.info(`Navigation blocked to: ${navigationUrl}`); } // 阻止加载 DevTools URL if (navigationUrl.startsWith("devtools://")) { event.preventDefault(); logger.info(`DevTools URL blocked: ${navigationUrl}`); } }); // 窗口关闭 this.mainWindow.on("closed", () => { logger.info("Window closed"); this.mainWindow = null; }); // 处理窗口加载失败 this.mainWindow.webContents.on( "did-fail-load", (_event, errorCode, errorDescription) => { const errorMsg = `Window failed to load: ${errorCode} - ${errorDescription}`; logger.error(errorMsg); console.error(errorMsg); // 连接被拒绝或名称解析失败 if (errorCode === -106 || errorCode === -105) { dialog.showErrorBox( "Connection Error", `Failed to connect to server at ${serverUrl}\n\nError: ${errorDescription}\n\nCheck logs at: ${logger.getLogFilePath()}`, ); } }, ); // 处理渲染进程崩溃 this.mainWindow.webContents.on("render-process-gone", (_event, details) => { const errorMsg = `Render process crashed: ${details.reason} (exit code: ${details.exitCode})`; logger.fatal(errorMsg); console.error(errorMsg); dialog.showErrorBox( "Application Crashed", `The application window crashed:\n${details.reason}\n\nCheck logs at: ${logger.getLogFilePath()}`, ); }); // 处理未捕获的异常 this.mainWindow.webContents.on("unresponsive", () => { logger.warn("Window became unresponsive"); }); this.mainWindow.webContents.on("responsive", () => { logger.info("Window became responsive again"); }); if (showLoading && this.mainWindow) { const loadingHtml = this.getLoadingPageHtml(); const dataUrl = `data:text/html;charset=utf-8,${encodeURIComponent(loadingHtml)}`; this.mainWindow.loadURL(dataUrl); } // 确保服务器已经启动后再加载 URL const loadWindow = async () => { try { // 确保服务器就绪 await this.waitForServer(serverUrl, 5000); logger.info(`Loading URL: ${serverUrl}`); if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.loadURL(serverUrl); } } catch (error) { logger.warn( `Failed to verify server, loading URL anyway: ${error instanceof Error ? error.message : String(error)}`, ); // 即使检查失败,也尝试加载(可能服务器刚启动) if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.loadURL(serverUrl); } } }; if (waitForServer) { // 延迟一点加载,确保窗口完全创建 setTimeout(() => { loadWindow(); }, 100); } } /** * 主动加载指定 URL(用于延迟加载) */ load(serverUrl: string): void { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.loadURL(serverUrl); } } /** * 内置加载界面 */ private getLoadingPageHtml(): string { return ` FreeTodo 加载中
正在启动服务...
`; } /** * 聚焦窗口 * 如果窗口最小化则恢复,然后聚焦 */ focus(): void { if (this.mainWindow) { if (this.mainWindow.isMinimized()) { this.mainWindow.restore(); } this.mainWindow.focus(); } } /** * 获取主窗口实例 */ getWindow(): BrowserWindow | null { return this.mainWindow; } /** * 检查窗口是否存在 */ hasWindow(): boolean { return this.mainWindow !== null; } /** * 检查是否有任何窗口打开 */ static hasAnyWindows(): boolean { return BrowserWindow.getAllWindows().length > 0; } } ================================================ FILE: free-todo-frontend/electron-builder.island.pyinstaller.yml ================================================ appId: com.freeugroup.freetodo.island productName: FreeTodo Island copyright: Copyright © 2026 FreeU Group directories: output: dist-artifacts/electron/island/pyinstaller buildResources: build files: - 'dist-electron/**/*' extraResources: - from: '.next/standalone' to: 'standalone' filter: - '**/*' - '!**/.pnpm/**' - from: '.next/standalone/node_modules' to: 'standalone/node_modules' filter: - '**/*' - from: '.next/static' to: 'standalone/.next/static' filter: - '**/*' - from: 'public' to: 'standalone/public' filter: - '**/*' - from: '../dist-backend' to: 'backend' filter: - '**/*' win: target: - target: nsis arch: - x64 icon: public/favicon.ico artifactName: '${productName}-${version}-pyinstaller-win-${arch}.${ext}' nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: false createDesktopShortcut: true createStartMenuShortcut: true include: build/installer.nsh mac: target: - target: dmg arch: - x64 - arm64 icon: public/logo.png category: public.app-category.productivity artifactName: '${productName}-${version}-pyinstaller-mac-${arch}.${ext}' dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications linux: target: - target: AppImage arch: - x64 icon: public/logo.png category: Utility artifactName: '${productName}-${version}-pyinstaller-linux-${arch}.${ext}' asar: false ================================================ FILE: free-todo-frontend/electron-builder.island.script.yml ================================================ appId: com.freeugroup.freetodo.island productName: FreeTodo Island copyright: Copyright © 2026 FreeU Group directories: output: dist-artifacts/electron/island/script buildResources: build files: - 'dist-electron/**/*' extraResources: - from: '.next/standalone' to: 'standalone' filter: - '**/*' - '!**/.pnpm/**' - from: '.next/standalone/node_modules' to: 'standalone/node_modules' filter: - '**/*' - from: '.next/static' to: 'standalone/.next/static' filter: - '**/*' - from: 'public' to: 'standalone/public' filter: - '**/*' - from: '../lifetrace' to: 'backend/lifetrace' filter: - '**/*' - '!**/__pycache__/**' - '!**/*.pyc' - '!**/data/**' - '!**/config/config.yaml' - '!**/.venv/**' - '!**/.ruff_cache/**' - from: '../requirements-runtime.txt' to: 'backend/requirements-runtime.txt' win: target: - target: nsis arch: - x64 icon: public/favicon.ico artifactName: '${productName}-${version}-script-win-${arch}.${ext}' nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: false createDesktopShortcut: true createStartMenuShortcut: true include: build/installer.nsh mac: target: - target: dmg arch: - x64 - arm64 icon: public/logo.png category: public.app-category.productivity artifactName: '${productName}-${version}-script-mac-${arch}.${ext}' dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications linux: target: - target: AppImage arch: - x64 icon: public/logo.png category: Utility artifactName: '${productName}-${version}-script-linux-${arch}.${ext}' asar: false ================================================ FILE: free-todo-frontend/electron-builder.island.yml ================================================ appId: com.freeugroup.freetodo productName: FreeTodo copyright: Copyright © 2026 FreeU Group directories: output: dist-artifacts/electron/island buildResources: build files: - 'dist-electron/**/*' extraResources: - from: '.next/standalone' to: 'standalone' filter: - '**/*' - '!**/.pnpm/**' - from: '.next/standalone/node_modules' to: 'standalone/node_modules' filter: - '**/*' - from: '.next/static' to: 'standalone/.next/static' filter: - '**/*' - from: 'public' to: 'standalone/public' filter: - '**/*' - from: '../lifetrace' to: 'backend/lifetrace' filter: - '**/*' - '!**/__pycache__/**' - '!**/*.pyc' - '!**/data/**' - '!**/config/config.yaml' - '!**/.venv/**' - '!**/.ruff_cache/**' - from: '../requirements-runtime.txt' to: 'backend/requirements-runtime.txt' win: target: - target: nsis arch: - x64 icon: public/favicon.ico artifactName: '${productName}-${version}-island-win-${arch}.${ext}' nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: false createDesktopShortcut: true createStartMenuShortcut: true include: build/installer.nsh mac: target: - target: dmg arch: - x64 - arm64 icon: public/logo.png category: public.app-category.productivity artifactName: '${productName}-${version}-island-mac-${arch}.${ext}' dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications linux: target: - target: AppImage arch: - x64 icon: public/logo.png category: Utility artifactName: '${productName}-${version}-island-linux-${arch}.${ext}' asar: false ================================================ FILE: free-todo-frontend/electron-builder.web.pyinstaller.yml ================================================ appId: com.freeugroup.freetodo productName: FreeTodo copyright: Copyright © 2026 FreeU Group directories: output: dist-artifacts/electron/web/pyinstaller buildResources: build files: - 'dist-electron/**/*' extraResources: - from: '.next/standalone' to: 'standalone' filter: - '**/*' - '!**/.pnpm/**' - from: '.next/standalone/node_modules' to: 'standalone/node_modules' filter: - '**/*' - from: '.next/static' to: 'standalone/.next/static' filter: - '**/*' - from: 'public' to: 'standalone/public' filter: - '**/*' - from: '../dist-backend' to: 'backend' filter: - '**/*' win: target: - target: nsis arch: - x64 icon: public/favicon.ico artifactName: '${productName}-${version}-web-pyinstaller-win-${arch}.${ext}' nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: false createDesktopShortcut: true createStartMenuShortcut: true include: build/installer.nsh mac: target: - target: dmg arch: - x64 - arm64 icon: public/logo.png category: public.app-category.productivity artifactName: '${productName}-${version}-web-pyinstaller-mac-${arch}.${ext}' dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications linux: target: - target: AppImage arch: - x64 icon: public/logo.png category: Utility artifactName: '${productName}-${version}-web-pyinstaller-linux-${arch}.${ext}' asar: false ================================================ FILE: free-todo-frontend/electron-builder.web.script.yml ================================================ appId: com.freeugroup.freetodo productName: FreeTodo copyright: Copyright © 2026 FreeU Group directories: output: dist-artifacts/electron/web/script buildResources: build files: - 'dist-electron/**/*' extraResources: - from: '.next/standalone' to: 'standalone' filter: - '**/*' - '!**/.pnpm/**' - from: '.next/standalone/node_modules' to: 'standalone/node_modules' filter: - '**/*' - from: '.next/static' to: 'standalone/.next/static' filter: - '**/*' - from: 'public' to: 'standalone/public' filter: - '**/*' - from: '../lifetrace' to: 'backend/lifetrace' filter: - '**/*' - '!**/__pycache__/**' - '!**/*.pyc' - '!**/data/**' - '!**/config/config.yaml' - '!**/.venv/**' - '!**/.ruff_cache/**' - from: '../requirements-runtime.txt' to: 'backend/requirements-runtime.txt' win: target: - target: nsis arch: - x64 icon: public/favicon.ico artifactName: '${productName}-${version}-web-script-win-${arch}.${ext}' nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: false createDesktopShortcut: true createStartMenuShortcut: true include: build/installer.nsh mac: target: - target: dmg arch: - x64 - arm64 icon: public/logo.png category: public.app-category.productivity artifactName: '${productName}-${version}-web-script-mac-${arch}.${ext}' dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications linux: target: - target: AppImage arch: - x64 icon: public/logo.png category: Utility artifactName: '${productName}-${version}-web-script-linux-${arch}.${ext}' asar: false ================================================ FILE: free-todo-frontend/electron-builder.web.yml ================================================ appId: com.freeugroup.freetodo productName: FreeTodo copyright: Copyright © 2026 FreeU Group directories: output: dist-artifacts/electron/web buildResources: build files: - 'dist-electron/**/*' extraResources: - from: '.next/standalone' to: 'standalone' filter: - '**/*' - '!**/.pnpm/**' - from: '.next/standalone/node_modules' to: 'standalone/node_modules' filter: - '**/*' - from: '.next/static' to: 'standalone/.next/static' filter: - '**/*' - from: 'public' to: 'standalone/public' filter: - '**/*' - from: '../lifetrace' to: 'backend/lifetrace' filter: - '**/*' - '!**/__pycache__/**' - '!**/*.pyc' - '!**/data/**' - '!**/config/config.yaml' - '!**/.venv/**' - '!**/.ruff_cache/**' - from: '../requirements-runtime.txt' to: 'backend/requirements-runtime.txt' win: target: - target: nsis arch: - x64 icon: public/favicon.ico artifactName: '${productName}-${version}-web-win-${arch}.${ext}' nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: false createDesktopShortcut: true createStartMenuShortcut: true include: build/installer.nsh mac: target: - target: dmg arch: - x64 - arm64 icon: public/logo.png category: public.app-category.productivity artifactName: '${productName}-${version}-web-mac-${arch}.${ext}' dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications linux: target: - target: AppImage arch: - x64 icon: public/logo.png category: Utility artifactName: '${productName}-${version}-web-linux-${arch}.${ext}' asar: false ================================================ FILE: free-todo-frontend/electron-builder.yml ================================================ appId: com.freeugroup.freetodo productName: FreeTodo copyright: Copyright © 2026 FreeU Group directories: output: dist-electron-app buildResources: build # 主进程文件 files: - 'dist-electron/**/*' # 将 Next.js standalone 构建结果作为资源打包 extraResources: # 复制 standalone 构建(包含 server.js 和必要的运行时文件) - from: '.next/standalone' to: 'standalone' filter: - '**/*' # 注意:.pnpm 目录在 resolve-symlinks 后应该已经不需要了 # 但如果仍有缺失依赖,可以取消下面的排除 - '!**/.pnpm/**' # 排除 .pnpm 目录(已通过 resolve-symlinks 解析) # 明确复制 node_modules(确保包含所有依赖) - from: '.next/standalone/node_modules' to: 'standalone/node_modules' filter: - '**/*' # 复制静态文件(CSS, JS chunks等)- Next.js standalone 不包含这些文件,必须单独复制 - from: '.next/static' to: 'standalone/.next/static' filter: - '**/*' # 复制 public 目录(图片、字体等静态资源) - from: 'public' to: 'standalone/public' filter: - '**/*' # 复制后端源码与依赖清单(运行时使用本机 Python) - from: '../lifetrace' to: 'backend/lifetrace' filter: - '**/*' - '!**/__pycache__/**' - '!**/*.pyc' - '!**/data/**' - '!**/config/config.yaml' - '!**/.venv/**' - '!**/.ruff_cache/**' - from: '../requirements-runtime.txt' to: 'backend/requirements-runtime.txt' # Windows 配置 win: target: - target: nsis arch: - x64 icon: public/favicon.ico artifactName: '${productName}-${version}-win-${arch}.${ext}' nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true deleteAppDataOnUninstall: false createDesktopShortcut: true createStartMenuShortcut: true include: build/installer.nsh # macOS 配置 mac: target: - target: dmg arch: - x64 - arm64 icon: public/logo.png category: public.app-category.productivity artifactName: '${productName}-${version}-mac-${arch}.${ext}' dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications # Linux 配置 linux: target: - target: AppImage arch: - x64 icon: public/logo.png category: Utility artifactName: '${productName}-${version}-linux-${arch}.${ext}' # 打包时不使用 asar(因为需要运行 Node.js 服务器) asar: false ================================================ FILE: free-todo-frontend/global.d.ts ================================================ import type messages from "./lib/i18n/messages/zh.json"; type Messages = typeof messages; declare global { // Use type safe message keys with `auto-complete` interface IntlMessages extends Messages {} // Cookie Store API 类型声明 interface CookieStoreSetOptions { name: string; value: string; expires?: number | Date; maxAge?: number; domain?: string; path?: string; sameSite?: "strict" | "lax" | "none"; secure?: boolean; partitioned?: boolean; } interface CookieStoreApi { set(options: CookieStoreSetOptions): Promise; set(name: string, value: string): Promise; get(name: string): Promise<{ name: string; value: string } | null>; delete(name: string): Promise; } interface Window { cookieStore?: CookieStoreApi; electronAPI?: { /** * 显示系统通知 * @param data 通知数据 */ showNotification: (data: { id: string; title: string; content: string; timestamp: string; }) => Promise; /** * 设置窗口是否忽略鼠标事件 */ setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; /** * 获取屏幕信息 */ getScreenInfo: () => Promise<{ screenWidth: number; screenHeight: number }>; /** * 移动窗口到指定位置 */ moveWindow: (x: number, y: number) => void; /** * 获取窗口当前位置 */ getWindowPosition: () => Promise<{ x: number; y: number }>; /** * 退出应用 */ quit: () => void; /** * 设置窗口背景色 */ setWindowBackgroundColor: (color: string) => void; // ========== Island 动态岛相关 API ========== /** * 调整 Island 窗口大小(切换模式) * @param mode Island 模式: "FLOAT" | "POPUP" | "SIDEBAR" | "FULLSCREEN" */ islandResizeWindow: (mode: string) => void; /** * 调整 SIDEBAR 模式窗口大小(多栏展开/收起) * @param columnCount 栏数: 1 | 2 | 3 */ islandResizeSidebar: (columnCount: number) => void; /** * 显示 Island 窗口 */ islandShow: () => void; /** * 隐藏 Island 窗口 */ islandHide: () => void; /** * 切换 Island 窗口显示/隐藏 */ islandToggle: () => void; /** * Island 窗口拖拽开始(自定义拖拽) * @param mouseY 鼠标屏幕 Y 坐标 */ islandDragStart: (mouseY: number) => void; /** * Island 窗口拖拽移动(自定义拖拽) * @param mouseY 鼠标屏幕 Y 坐标 */ islandDragMove: (mouseY: number) => void; /** * Island 窗口拖拽结束(自定义拖拽) */ islandDragEnd: () => void; /** * 设置 Island SIDEBAR 模式的固定状态 * @param isPinned true = 固定(始终在顶部),false = 非固定(正常窗口行为) */ islandSetPinned: (isPinned: boolean) => void; /** * 监听 Island 窗口位置更新(拖拽时实时更新) * @param callback 回调函数,接收位置数据 { y: number, screenHeight: number } * @returns 清理函数,用于取消监听 */ onIslandPositionUpdate: (callback: (data: { y: number; screenHeight: number }) => void) => () => void; /** * 监听 Island 窗口锚点更新(模式切换时更新) * @param callback 回调函数,接收锚点数据 { anchor: 'top' | 'bottom' | null, y: number } * @returns 清理函数,用于取消监听 */ onIslandAnchorUpdate: (callback: (data: { anchor: 'top' | 'bottom' | null; y: number }) => void) => () => void; // ========== Future Extensions ========== /** * 监听 Island 窗口可见性变化(未来功能) * @param callback 回调函数,接收可见性状态 */ onIslandVisibilityChange?: (callback: (visible: boolean) => void) => void; /** * 取消监听 Island 窗口可见性变化(未来功能) */ offIslandVisibilityChange?: (callback: (visible: boolean) => void) => void; }; } } ================================================ FILE: free-todo-frontend/lib/api/fetcher.ts ================================================ import type { ZodType } from "zod"; import { camelToSnake, snakeToCamel } from "../generated/case-transform"; type CustomFetcherOptions = RequestInit & { params?: Record; data?: unknown; responseSchema?: ZodType; }; // 标准化时间字符串(处理无时区后缀问题) function normalizeTimestamps(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj === "string") { // ISO 时间格式但无时区,假设为 UTC if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(obj)) { return `${obj}Z`; } return obj; } if (Array.isArray(obj)) { return obj.map(normalizeTimestamps); } if (typeof obj === "object") { return Object.fromEntries( Object.entries(obj).map(([k, v]) => [k, normalizeTimestamps(v)]), ); } return obj; } const normalizeHeaders = (headers?: HeadersInit): Record => { const normalized: Record = {}; if (headers instanceof Headers) { headers.forEach((value, key) => { normalized[key] = value; }); return normalized; } if (Array.isArray(headers)) { for (const [key, value] of headers) { normalized[key] = value; } return normalized; } if (headers) { Object.assign(normalized, headers); } return normalized; }; const shouldParseBody = (body: BodyInit | null | undefined): unknown => { if (typeof body !== "string") return undefined; const trimmed = body.trim(); if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined; try { return JSON.parse(trimmed); } catch { return undefined; } }; export const unwrapApiData = (response: unknown): T | null => { if (response === null || response === undefined) return null; if (typeof response === "object" && response !== null && "data" in response) { const data = (response as { data?: T }).data; return data ?? null; } return response as T; }; export async function customFetcher( url: string, options?: CustomFetcherOptions, ): Promise { // 客户端使用相对路径(通过 Next.js rewrites 代理) // SSR 环境使用环境变量(由 Electron 启动时注入动态端口) const baseUrl = typeof window !== "undefined" ? "" : process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"; const { params, data, responseSchema, body, headers, ...fetchOptions } = options ?? {}; const filteredParams = params ? Object.fromEntries( Object.entries(params).filter( ([_, value]) => value !== undefined && value !== null, ), ) : {}; const [path, existingQuery = ""] = url.split("?"); const queryParams = new URLSearchParams(existingQuery); Object.entries(filteredParams).forEach(([key, value]) => { queryParams.append(key, String(value)); }); const queryString = queryParams.toString(); const finalUrl = queryString.length > 0 ? `${path}?${queryString}` : url; let requestBody: BodyInit | undefined = body ?? undefined; let isJsonBody = false; if (data !== undefined) { requestBody = JSON.stringify(camelToSnake(data)); isJsonBody = true; } else if (body !== undefined) { const parsed = shouldParseBody(body); if (parsed !== undefined) { requestBody = JSON.stringify(camelToSnake(parsed)); isJsonBody = true; } } const finalHeaders = normalizeHeaders(headers); if (isJsonBody) { const hasContentType = Object.keys(finalHeaders).some( (key) => key.toLowerCase() === "content-type", ); if (!hasContentType) { finalHeaders["Content-Type"] = "application/json"; } } const fetchInit: RequestInit = { ...fetchOptions, headers: finalHeaders, }; if (requestBody !== undefined) { fetchInit.body = requestBody; } const response = await fetch(`${baseUrl}${finalUrl}`, fetchInit); if (!response.ok) { throw new Error(`API Error: ${response.status}`); } // 处理空响应体(如 204 No Content 或 DELETE 操作) const contentType = response.headers.get("content-type"); const contentLength = response.headers.get("content-length"); if (response.status === 204 || contentLength === "0") { return undefined as T; } const text = await response.text(); if (!text || text.trim() === "") { return undefined as T; } let json: unknown; try { json = JSON.parse(text); } catch (error) { if (!contentType?.includes("application/json")) { return text as T; } throw new Error( `Failed to parse JSON response: ${ error instanceof Error ? error.message : String(error) }`, ); } json = normalizeTimestamps(json); json = snakeToCamel(json); if (responseSchema) { const result = responseSchema.safeParse(json); if (!result.success) { console.error("[API] Schema validation failed:", result.error.issues); if (process.env.NODE_ENV === "development") { throw new Error("Schema validation failed"); } } return result.success ? result.data : (json as T); } return json as T; } ================================================ FILE: free-todo-frontend/lib/api.ts ================================================ /** * 获取流式 API 的基础 URL * 流式请求直接调用后端 API,绕过 Next.js 代理,避免 gzip 压缩破坏流式传输 */ function getStreamApiBaseUrl(): string { // 流式请求始终直接调用后端,避免 Next.js 代理导致的缓冲/压缩问题 return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"; } // ============================================================================ // 流式 API(Orval 不支持 Server-Sent Events,需要手动实现) // ============================================================================ export interface SendChatParams { message: string; // 发送给 LLM 的完整消息(包含 system prompt + context + user input) userInput?: string; // 用户真正输入的内容(用于保存到历史记录) context?: string; // 待办上下文(可选) systemPrompt?: string; // 系统提示词(可选) conversationId?: string; useRag?: boolean; mode?: string; selectedTools?: string[]; externalTools?: string[]; } /** * 工具调用事件类型(从后端流式响应中解析) */ export type ToolCallEventType = | "tool_call_start" | "tool_call_end" | "run_started" | "run_completed"; /** * 工具调用事件数据 */ export interface ToolCallEvent { type: ToolCallEventType; tool_name?: string; tool_args?: Record; result_preview?: string; } // 工具调用事件标记(与后端保持一致) const TOOL_EVENT_PREFIX = "\n[TOOL_EVENT:"; const TOOL_EVENT_SUFFIX = "]\n"; /** * 解析流式响应中的工具调用事件 * 返回 [解析出的事件列表, 剩余的纯内容] */ function parseToolEvents(chunk: string): [ToolCallEvent[], string] { const events: ToolCallEvent[] = []; let content = chunk; // 循环查找并解析所有工具调用事件 let startIdx = content.indexOf(TOOL_EVENT_PREFIX); while (startIdx !== -1) { const endIdx = content.indexOf(TOOL_EVENT_SUFFIX, startIdx); if (endIdx === -1) { // 事件标记不完整,等待更多数据 break; } // 提取 JSON 部分 const jsonStart = startIdx + TOOL_EVENT_PREFIX.length; const jsonStr = content.substring(jsonStart, endIdx); try { const event = JSON.parse(jsonStr) as ToolCallEvent; events.push(event); } catch (e) { console.error("[parseToolEvents] Failed to parse event:", jsonStr, e); } // 移除已解析的事件标记 content = content.substring(0, startIdx) + content.substring(endIdx + TOOL_EVENT_SUFFIX.length); // 继续查找下一个事件 startIdx = content.indexOf(TOOL_EVENT_PREFIX); } return [events, content]; } /** * 发送聊天消息并以流式方式接收回复 * @param params - 聊天参数 * @param onChunk - 内容块回调 * @param onSessionId - 会话 ID 回调 * @param signal - 取消信号 * @param locale - 语言设置 * @param onToolEvent - 工具调用事件回调(可选) */ export async function sendChatMessageStream( params: SendChatParams, onChunk: (chunk: string) => void, onSessionId?: (sessionId: string) => void, signal?: AbortSignal, locale?: string, onToolEvent?: (event: ToolCallEvent) => void, ): Promise { // 流式请求直接调用后端 API,绕过 Next.js 代理 const baseUrl = getStreamApiBaseUrl(); const apiUrl = `${baseUrl}/api/chat/stream`; // 调试日志 console.log("[sendChatMessageStream] baseUrl:", baseUrl); console.log("[sendChatMessageStream] apiUrl:", apiUrl); console.log("[sendChatMessageStream] params:", params); console.log("[sendChatMessageStream] selectedTools:", params.selectedTools); let response: Response; try { const requestBody = { message: params.message, user_input: params.userInput, context: params.context, system_prompt: params.systemPrompt, conversation_id: params.conversationId, use_rag: params.useRag, mode: params.mode, selected_tools: params.selectedTools, external_tools: params.externalTools, }; console.log("[sendChatMessageStream] Request body:", requestBody); response = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", "Accept-Language": locale || "en", }, body: JSON.stringify(requestBody), signal, }); } catch (error) { // 调试日志 console.error("[sendChatMessageStream] fetch error:", error); // 如果是取消操作,静默返回 if ( signal?.aborted || (error instanceof Error && error.name === "AbortError") ) { return; } throw error; } if (!response.ok) { throw new Error(`Request failed with status ${response.status}`); } // 从响应头中获取 session_id const sessionId = response.headers.get("X-Session-Id"); if (sessionId && onSessionId) { onSessionId(sessionId); } if (!response.body) { throw new Error("ReadableStream is not supported in this environment"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); // 用于处理跨 chunk 的不完整事件标记 let pendingChunk = ""; try { while (true) { // 检查是否已取消 if (signal?.aborted) { await reader.cancel(); break; } const { done, value } = await reader.read(); if (done) break; if (value) { const rawChunk = decoder.decode(value, { stream: true }); if (rawChunk) { // 将待处理的部分与新数据合并 const fullChunk = pendingChunk + rawChunk; // 解析工具调用事件 const [events, content] = parseToolEvents(fullChunk); // 触发工具调用事件回调 if (onToolEvent) { for (const event of events) { onToolEvent(event); } } // 检查是否有不完整的事件标记 const incompleteEventIdx = content.indexOf(TOOL_EVENT_PREFIX); if (incompleteEventIdx !== -1) { // 有不完整的事件标记,保存到下次处理 pendingChunk = content.substring(incompleteEventIdx); const completeContent = content.substring(0, incompleteEventIdx); if (completeContent) { onChunk(completeContent); } } else { // 没有不完整的事件标记 pendingChunk = ""; if (content) { onChunk(content); } } } } } // 处理最后剩余的内容 if (pendingChunk) { onChunk(pendingChunk); } } catch (error) { // 如果是取消操作,不抛出错误 if ( signal?.aborted || (error instanceof Error && error.name === "AbortError") ) { await reader.cancel(); return; } throw error; } } /** * Plan功能:生成选择题(流式输出) */ export async function planQuestionnaireStream( todoName: string, onChunk: (chunk: string) => void, todoId?: number, ): Promise { // 流式请求直接调用后端 API,绕过 Next.js 代理 const baseUrl = getStreamApiBaseUrl(); const response = await fetch( `${baseUrl}/api/chat/plan/questionnaire/stream`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ todo_name: todoName, todo_id: todoId, }), }, ); if (!response.ok) { throw new Error(`Request failed with status ${response.status}`); } if (!response.body) { throw new Error("ReadableStream is not supported in this environment"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { const chunk = decoder.decode(value, { stream: true }); if (chunk) { onChunk(chunk); } } } } /** * Plan功能:生成任务总结和子任务(流式输出) */ export async function planSummaryStream( todoName: string, answers: Record, onChunk: (chunk: string) => void, ): Promise { // 流式请求直接调用后端 API,绕过 Next.js 代理 const baseUrl = getStreamApiBaseUrl(); const response = await fetch(`${baseUrl}/api/chat/plan/summary/stream`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ todo_name: todoName, answers: answers, }), }); if (!response.ok) { throw new Error(`Request failed with status ${response.status}`); } if (!response.body) { throw new Error("ReadableStream is not supported in this environment"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { const chunk = decoder.decode(value, { stream: true }); if (chunk) { onChunk(chunk); } } } } // ============================================================================ // 工具函数 // ============================================================================ /** * 获取截图图片 URL * 辅助函数,用于构建截图图片的 URL */ export function getScreenshotImage(id: number): string { // 在客户端使用相对路径,通过 Next.js rewrites 代理 // 在服务端使用完整 URL const baseUrl = typeof window !== "undefined" ? "" : process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"; return `${baseUrl}/api/screenshots/${id}/image`; } // ============================================================================ // 类型导出(从 Orval 生成的 schemas 重新导出,保持向后兼容) // ============================================================================ // 这些类型现在应该从 @/lib/generated/schemas 导入 // 保留这些重导出以保持向后兼容 export type { ExtractedTodo, ManualActivityCreateRequest, ManualActivityCreateResponse, TodoAttachmentResponse as ApiTodoAttachment, TodoCreate, TodoExtractionResponse, TodoPriority, TodoResponse as ApiTodo, TodoStatus, TodoTimeInfo, TodoUpdate, } from "@/lib/generated/schemas"; // Chat 相关类型(这些类型在后端 OpenAPI spec 中可能没有定义,手动定义) // 注意:使用 camelCase,因为 fetcher 会自动将后端的 snake_case 转换为 camelCase export type ChatSessionSummary = { sessionId: string; title?: string; lastActive?: string; messageCount?: number; chatType?: string; }; export type ChatHistoryItem = { role: "user" | "assistant"; content: string; timestamp?: string; extraData?: string; }; export type ChatHistoryResponse = { sessions?: ChatSessionSummary[]; history?: ChatHistoryItem[]; }; // ============================================================================ // 注:通知相关的 API(fetchNotification、deleteNotification)已被 Orval 生成的 API 替换 // 请直接使用 @/lib/generated/notifications/notifications 中的函数 // ============================================================================ ================================================ FILE: free-todo-frontend/lib/attachments.ts ================================================ "use client"; import { snakeToCamel } from "@/lib/generated/case-transform"; import type { TodoAttachment } from "@/lib/types"; export const MAX_ATTACHMENT_SIZE_BYTES = 50 * 1024 * 1024; function getApiBaseUrl(): string { return typeof window !== "undefined" ? "" : process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"; } export function getAttachmentFileUrl(attachmentId: number): string { const baseUrl = getApiBaseUrl(); return `${baseUrl}/api/todos/attachments/${attachmentId}/file`; } export async function uploadTodoAttachments( todoId: number, files: File[], ): Promise { const baseUrl = getApiBaseUrl(); const formData = new FormData(); for (const file of files) { formData.append("files", file, file.name); } const response = await fetch(`${baseUrl}/api/todos/${todoId}/attachments`, { method: "POST", body: formData, }); if (!response.ok) { throw new Error(`Upload failed: ${response.status}`); } const json = await response.json(); return snakeToCamel(json) as TodoAttachment[]; } export async function removeTodoAttachment( todoId: number, attachmentId: number, ): Promise { const baseUrl = getApiBaseUrl(); const response = await fetch( `${baseUrl}/api/todos/${todoId}/attachments/${attachmentId}`, { method: "DELETE", }, ); if (!response.ok) { throw new Error(`Remove attachment failed: ${response.status}`); } } ================================================ FILE: free-todo-frontend/lib/config/panel-config.ts ================================================ /** * Panel 配置层 * 定义功能到位置的映射关系 * 现在使用动态分配系统,功能可以动态分配到位置 */ import { Activity, Award, BookOpen, CalendarDays, Camera, DollarSign, FileText, ListTodo, type LucideIcon, MessageSquare, Mic, Settings, } from "lucide-react"; export type PanelPosition = "panelA" | "panelB" | "panelC"; export type PanelFeature = | "calendar" | "activity" | "todos" | "chat" | "todoDetail" | "diary" | "settings" | "costTracking" | "achievements" | "debugShots" | "audio"; /** * 开发中的面板功能列表 * 这些功能默认在 UI 中处于关闭状态,由用户手动开启 * 在设置面板的"开发选项"中统一管理 */ export const DEV_IN_PROGRESS_FEATURES: PanelFeature[] = [ "diary", "activity", "debugShots", "achievements", "audio", ]; /** * 所有可用的功能列表 */ export const ALL_PANEL_FEATURES: PanelFeature[] = [ "calendar", "activity", "todos", "chat", "todoDetail", "diary", "settings", "costTracking", "achievements", "debugShots", "audio", ]; /** * 功能到图标的映射配置 */ export const FEATURE_ICON_MAP: Record = { calendar: CalendarDays, activity: Activity, todos: ListTodo, chat: MessageSquare, todoDetail: FileText, diary: BookOpen, settings: Settings, costTracking: DollarSign, achievements: Award, debugShots: Camera, audio: Mic, }; ================================================ FILE: free-todo-frontend/lib/dnd/context.tsx ================================================ "use client"; /** * 全局拖拽上下文提供者 * Global Drag and Drop Context Provider */ import { type CollisionDetection, closestCenter, DndContext, type DragCancelEvent, type DragEndEvent, type DragStartEvent, PointerSensor, pointerWithin, rectIntersection, useSensor, useSensors, } from "@dnd-kit/core"; import { createContext, useCallback, useContext, useState } from "react"; import { dispatchDragDrop } from "./handlers"; import { GlobalDragOverlay } from "./overlays"; import type { ActiveDragState, DragData, DropData, GlobalDndContextValue, } from "./types"; // ============================================================================ // Context 创建 // ============================================================================ // 用于跟踪正在进行乐观更新的 todo,确保在数据同步前卡片保持隐藏 export const PendingUpdateContext = createContext<{ pendingTodoId: number | null; setPendingTodoId: (id: number | null) => void; } | null>(null); const GlobalDndContext = createContext(null); /** * 使用全局拖拽上下文 */ export function useGlobalDnd(): GlobalDndContextValue { const context = useContext(GlobalDndContext); if (!context) { throw new Error("useGlobalDnd must be used within GlobalDndProvider"); } return context; } /** * 安全获取全局拖拽上下文(可能为 null) */ export function useGlobalDndSafe(): GlobalDndContextValue | null { return useContext(GlobalDndContext); } /** * 获取正在进行乐观更新的 todo ID * 用于在数据同步完成前保持被拖拽的卡片隐藏 */ export function usePendingUpdate() { const context = useContext(PendingUpdateContext); return context?.pendingTodoId ?? null; } // ============================================================================ // 自定义碰撞检测 // ============================================================================ /** * 自定义碰撞检测策略 * 优先使用 pointerWithin,然后 rectIntersection,最后 closestCenter */ const customCollisionDetection: CollisionDetection = (args) => { // 首先尝试 pointerWithin(指针在目标内部) const pointerCollisions = pointerWithin(args); if (pointerCollisions.length > 0) { return pointerCollisions; } // 然后尝试 rectIntersection(矩形相交) const rectCollisions = rectIntersection(args); if (rectCollisions.length > 0) { return rectCollisions; } // 最后使用 closestCenter(最近中心点) return closestCenter(args); }; // ============================================================================ // Provider 组件 // ============================================================================ interface GlobalDndProviderProps { children: React.ReactNode; } export function GlobalDndProvider({ children }: GlobalDndProviderProps) { const [activeDrag, setActiveDrag] = useState(null); // 跟踪正在进行乐观更新的 todo ID,用于在数据同步完成前保持卡片隐藏 const [pendingTodoId, setPendingTodoId] = useState(null); // 配置传感器 const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // 需要移动 8px 才触发拖拽,避免误触 }, }), ); // 拖拽开始 const handleDragStart = useCallback((event: DragStartEvent) => { const data = event.active.data.current as DragData | undefined; if (data) { // 保持原始 ID 类型,不做转换 // Calendar 使用 "calendar-{id}" 格式,TodoList 使用数字 ID setActiveDrag({ id: event.active.id, data, }); console.log("[DnD] Drag started:", data.type, event.active.id); } }, []); // 拖拽结束 const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; if (over) { const dragData = active.data.current as DragData | undefined; const dropData = over.data.current as DropData | undefined; console.log("[DnD] Drag ended:", { activeId: active.id, overId: over.id, dragType: dragData?.type, dropType: dropData?.type, }); // 提取被拖拽的 todo ID,用于乐观更新期间保持卡片隐藏 if (dragData?.type === "TODO_CARD") { const todoId = dragData.payload.todo.id; setPendingTodoId(todoId); // 在短暂延迟后清除 pending 状态,让 React Query 有时间传播更新 setTimeout(() => { setPendingTodoId(null); }, 150); } // 分发到对应的处理器 dispatchDragDrop(dragData, dropData); } setActiveDrag(null); }, []); // 拖拽取消 const handleDragCancel = useCallback((event: DragCancelEvent) => { console.log("[DnD] Drag cancelled:", event.active.id); setActiveDrag(null); }, []); const contextValue: GlobalDndContextValue = { activeDrag, }; const pendingUpdateValue = { pendingTodoId, setPendingTodoId, }; return ( {children} ); } // ============================================================================ // 导出 // ============================================================================ export { GlobalDndContext }; ================================================ FILE: free-todo-frontend/lib/dnd/handlers.ts ================================================ /** * 拖拽处理器 - 策略模式分发 * Drag Drop Handlers - Strategy Pattern Dispatch */ import { flushSync } from "react-dom"; import type { TodoListResponse, TodoResponse } from "@/lib/generated/schemas"; import { updateTodoApiTodosTodoIdPut } from "@/lib/generated/todos/todos"; import { getQueryClient, queryKeys } from "@/lib/query"; import { useUiStore } from "@/lib/store/ui-store"; import type { DragData, DragDropHandler, DragDropResult, DropData, HandlerKey, } from "./types"; // ============================================================================ // 处理器注册表 (Handler Registry) // ============================================================================ /** * 策略模式处理器映射表 * 键格式: "SOURCE_TYPE->TARGET_TYPE" */ const handlerRegistry: Partial> = {}; // Normalize date strings that may lack timezone info. const normalizeTodoDate = (value?: string) => { if (!value) return null; let normalized = value; if ( value.includes("T") && !value.includes("Z") && !value.includes("+") && !/\d{2}:\d{2}:\d{2}-/.test(value) ) { normalized = `${value}Z`; } const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? null : parsed; }; const updateTodoCache = ( todoId: number, updates: { deadline?: string; startTime?: string; endTime?: string; }, ) => { const queryClient = getQueryClient(); void queryClient.cancelQueries({ queryKey: queryKeys.todos.all }); const previousTodos = queryClient.getQueryData(queryKeys.todos.list()); flushSync(() => { queryClient.setQueryData( queryKeys.todos.list(), (oldData) => { if (!oldData) return oldData; if (oldData && "todos" in oldData && Array.isArray(oldData.todos)) { const updatedTodos = oldData.todos.map((t: TodoResponse) => { if (t.id !== todoId) return t; const tRecord = t as unknown as Record; const updated = { ...t, ...(updates.deadline ? { deadline: updates.deadline } : {}), } as Record; if ("start_time" in tRecord) { updated.start_time = updates.startTime ?? tRecord.start_time; } if ("startTime" in tRecord) { updated.startTime = updates.startTime ?? tRecord.startTime; } if ("end_time" in tRecord) { updated.end_time = updates.endTime ?? tRecord.end_time; } if ("endTime" in tRecord) { updated.endTime = updates.endTime ?? tRecord.endTime; } return updated as unknown as TodoResponse; }); return { ...oldData, todos: updatedTodos, }; } if (Array.isArray(oldData)) { return oldData.map((t) => t.id === todoId ? { ...t, ...(updates.deadline ? { deadline: updates.deadline } : {}), ...(updates.startTime ? { startTime: updates.startTime } : {}), ...(updates.endTime ? { endTime: updates.endTime } : {}), } : t, ) as unknown as TodoListResponse; } return oldData; }, ); }); return previousTodos; }; /** * 注册拖拽处理器 */ export function registerHandler(key: HandlerKey, handler: DragDropHandler) { handlerRegistry[key] = handler; } /** * 获取处理器 */ export function getHandler(key: HandlerKey): DragDropHandler | undefined { return handlerRegistry[key]; } // ============================================================================ // 内置处理器 (Built-in Handlers) // ============================================================================ /** * TODO_CARD -> CALENDAR_DATE * 将待办拖到日历日期上,设置 startTime/endTime * 使用乐观更新:先更新前端缓存,再调用 API */ const handleTodoToCalendarDate: DragDropHandler = ( dragData, dropData, ): DragDropResult => { if (dragData.type !== "TODO_CARD" || dropData.type !== "CALENDAR_DATE") { return { success: false, message: "Invalid drag/drop type combination" }; } const { todo } = dragData.payload; const { date } = dropData.metadata; const applyDate = (targetDate: Date, timeSource: Date) => { const updated = new Date(targetDate); updated.setHours( timeSource.getHours(), timeSource.getMinutes(), timeSource.getSeconds(), timeSource.getMilliseconds(), ); return updated; }; const existingStart = normalizeTodoDate(todo.startTime); const existingEnd = normalizeTodoDate(todo.endTime); const baseStart = existingStart; const durationMs = existingStart && existingEnd ? existingEnd.getTime() - existingStart.getTime() : null; const newStart = baseStart ? applyDate(date, baseStart) : applyDate(date, new Date(0)); if (!baseStart) { // 默认设置为上午9点 newStart.setHours(9, 0, 0, 0); } const newEnd = existingEnd ? durationMs !== null ? new Date(newStart.getTime() + durationMs) : applyDate(date, existingEnd) : null; const newStartStr = newStart ? newStart.toISOString() : undefined; const newEndStr = newEnd ? newEnd.toISOString() : undefined; const queryClient = getQueryClient(); // 取消正在进行的 todos 查询,避免竞态条件 void queryClient.cancelQueries({ queryKey: queryKeys.todos.all }); // 保存旧数据用于回滚 const previousTodos = queryClient.getQueryData(queryKeys.todos.list()); // 乐观更新:使用 flushSync 强制同步渲染,确保在 onDragEnd 返回前 UI 已更新 // 这样可以避免 "先弹回再闪现" 的视觉 Bug flushSync(() => { queryClient.setQueryData( queryKeys.todos.list(), (oldData) => { if (!oldData) return oldData; // 处理原始 API 响应结构 { total, todos: TodoResponse[] } if (oldData && "todos" in oldData && Array.isArray(oldData.todos)) { const updatedTodos = oldData.todos.map((t: TodoResponse) => { if (t.id === todo.id) { const tRecord = t as unknown as Record; const updated = { ...t, } as Record; if ("start_time" in tRecord) { updated.start_time = newStartStr ?? tRecord.start_time; } if ("startTime" in tRecord) { updated.startTime = newStartStr ?? tRecord.startTime; } if ("end_time" in tRecord) { updated.end_time = newEndStr ?? tRecord.end_time; } if ("endTime" in tRecord) { updated.endTime = newEndStr ?? tRecord.endTime; } return updated as unknown as TodoResponse; } return t; }); return { ...oldData, todos: updatedTodos, }; } // 向后兼容:如果是数组格式(不应该发生,但为了安全) if (Array.isArray(oldData)) { return oldData.map((t) => t.id === todo.id ? { ...t, startTime: newStartStr ?? t.startTime, endTime: newEndStr ?? t.endTime, } : t, ) as unknown as TodoListResponse; } return oldData; }, ); }); // 异步调用 API void updateTodoApiTodosTodoIdPut(todo.id, { ...(newStartStr ? { start_time: newStartStr } : {}), ...(newEndStr ? { end_time: newEndStr } : {}), }) .then(() => { // API 成功后刷新缓存以确保数据一致性 void getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all }); }) .catch((error) => { // API 失败时回滚到之前的数据 console.error("[DnD] Failed to update schedule:", error); if (previousTodos) { getQueryClient().setQueryData(queryKeys.todos.list(), previousTodos); } void getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all }); }); return { success: true, message: `已将 "${todo.name}" 设置到 ${dropData.metadata.dateKey}`, }; }; /** * TODO_CARD -> CALENDAR_TIMELINE_SLOT * Move todo into timeline slot (deadline or start/end). */ const handleTodoToCalendarTimelineSlot: DragDropHandler = ( dragData, dropData, ): DragDropResult => { if ( dragData.type !== "TODO_CARD" || dropData.type !== "CALENDAR_TIMELINE_SLOT" ) { return { success: false, message: "Invalid drag/drop type combination" }; } const { todo } = dragData.payload; const { date, minutes } = dropData.metadata; const slotDate = new Date(date); slotDate.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0); const existingStart = normalizeTodoDate(todo.startTime); const existingEnd = normalizeTodoDate(todo.endTime); const existingDeadline = normalizeTodoDate(todo.deadline); const hasRange = Boolean(existingStart || existingEnd); const MINUTES_PER_SLOT = 15; const DEFAULT_DURATION_MINUTES = 30; const getDurationMinutes = () => { if (existingStart && existingEnd) { const diff = (existingEnd.getTime() - existingStart.getTime()) / 60000; if (Number.isFinite(diff) && diff > 0) return diff; } return DEFAULT_DURATION_MINUTES; }; const rawDuration = getDurationMinutes(); const snappedDuration = Math.max( MINUTES_PER_SLOT, Math.round(rawDuration / MINUTES_PER_SLOT) * MINUTES_PER_SLOT, ); let newDeadline: Date | null = null; let newStart: Date | null = null; let newEnd: Date | null = null; if (hasRange) { newStart = slotDate; newEnd = new Date(slotDate.getTime() + snappedDuration * 60000); } else if (existingDeadline) { newDeadline = slotDate; } else { newStart = slotDate; newEnd = new Date(slotDate.getTime() + DEFAULT_DURATION_MINUTES * 60000); } const newDeadlineStr = newDeadline ? newDeadline.toISOString() : undefined; const newStartStr = newStart ? newStart.toISOString() : undefined; const newEndStr = newEnd ? newEnd.toISOString() : undefined; const previousTodos = updateTodoCache(todo.id, { ...(newDeadlineStr ? { deadline: newDeadlineStr } : {}), ...(newStartStr ? { startTime: newStartStr } : {}), ...(newEndStr ? { endTime: newEndStr } : {}), }); void updateTodoApiTodosTodoIdPut(todo.id, { ...(newDeadlineStr ? { deadline: newDeadlineStr } : {}), ...(newStartStr ? { startTime: newStartStr } : {}), ...(newEndStr ? { endTime: newEndStr } : {}), }) .then(() => { void getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all }); }) .catch((error) => { console.error("[DnD] Failed to update timeline slot:", error); if (previousTodos) { getQueryClient().setQueryData(queryKeys.todos.list(), previousTodos); } void getQueryClient().invalidateQueries({ queryKey: queryKeys.todos.all }); }); return { success: true }; }; /** * TODO_CARD -> TODO_LIST * 待办在列表内重新排序 * 注意:内部排序由 TodoList 组件通过 useDndMonitor 处理 * 使用乐观更新:先更新前端缓存,再调用 API */ const handleTodoToTodoList: DragDropHandler = ( dragData, dropData, ): DragDropResult => { if (dragData.type !== "TODO_CARD" || dropData.type !== "TODO_LIST") { return { success: false, message: "Invalid drag/drop type combination" }; } const { todo } = dragData.payload; const { parentTodoId } = dropData.metadata; // 如果指定了父级 ID,更新父子关系 if (parentTodoId !== undefined) { const queryClient = getQueryClient(); // 取消正在进行的 todos 查询 void queryClient.cancelQueries({ queryKey: queryKeys.todos.all }); // 保存旧数据用于回滚 const previousTodos = queryClient.getQueryData(queryKeys.todos.list()); // 乐观更新:立即更新前端缓存(使用与 useTodos 相同的 key) queryClient.setQueryData( queryKeys.todos.list(), (oldData) => { if (!oldData) return oldData; // 处理原始 API 响应结构 { total, todos: TodoResponse[] } if (oldData && "todos" in oldData && Array.isArray(oldData.todos)) { const updatedTodos = oldData.todos.map((t: TodoResponse) => { if (t.id === todo.id) { return { ...t, parent_todo_id: parentTodoId ?? null, }; } return t; }); return { ...oldData, todos: updatedTodos, }; } // 向后兼容:如果是数组格式(不应该发生,但为了安全) if (Array.isArray(oldData)) { return oldData.map((t) => t.id === todo.id ? { ...t, parentTodoId: parentTodoId ?? null } : t, ) as unknown as TodoListResponse; } return oldData; }, ); void updateTodoApiTodosTodoIdPut(todo.id, { parent_todo_id: parentTodoId ?? null, }) .then(() => { void queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); }) .catch((error) => { console.error("[DnD] Failed to update parent:", error); if (previousTodos) { queryClient.setQueryData(queryKeys.todos.list(), previousTodos); } void queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); }); } // 注意:列表内部排序由 TodoList 组件的 useDndMonitor 处理 return { success: true }; }; /** * TODO_CARD -> TODO_CARD_SLOT * 待办拖到另一个待办的前面或后面 */ const handleTodoToTodoCardSlot: DragDropHandler = ( dragData, dropData, ): DragDropResult => { if (dragData.type !== "TODO_CARD" || dropData.type !== "TODO_CARD_SLOT") { return { success: false, message: "Invalid drag/drop type combination" }; } // TODO: 实现插入逻辑 return { success: true }; }; /** * TODO_CARD -> TODO_DROP_ZONE * 将待办设置为另一个待办的子任务 * 注意:实际的父子关系设置由 TodoList 组件处理,这里主要做记录 */ const handleTodoToTodoDropZone: DragDropHandler = ( dragData, dropData, ): DragDropResult => { if (dragData.type !== "TODO_CARD" || dropData.type !== "TODO_DROP_ZONE") { return { success: false, message: "Invalid drag/drop type combination" }; } const { todo } = dragData.payload; const { todoId, position } = dropData.metadata; if (position === "nest") { console.log(`[DnD] 设置 "${todo.name}" 为 todo ${todoId} 的子任务`); // 实际的 API 调用由 TodoList 组件的 handleInternalReorder 处理 return { success: true, message: `已将 "${todo.name}" 设置为子任务`, }; } return { success: false, message: "Unknown position" }; }; /** * PANEL_HEADER -> PANEL_HEADER * 交换两个面板的位置(功能分配) */ const handlePanelHeaderToPanelHeader: DragDropHandler = ( dragData, dropData, ): DragDropResult => { if (dragData.type !== "PANEL_HEADER" || dropData.type !== "PANEL_HEADER") { return { success: false, message: "Invalid drag/drop type combination" }; } const { position: sourcePosition } = dragData.payload; const { position: targetPosition } = dropData.metadata; // 如果源位置和目标位置相同,不需要交换 if (sourcePosition === targetPosition) { return { success: false, message: "Cannot swap panel with itself" }; } // 交换面板位置 useUiStore.getState().swapPanelPositions(sourcePosition, targetPosition); return { success: true, message: `已交换 ${sourcePosition} 和 ${targetPosition} 的位置`, }; }; // ============================================================================ // 注册内置处理器 // ============================================================================ registerHandler("TODO_CARD->CALENDAR_DATE", handleTodoToCalendarDate); registerHandler( "TODO_CARD->CALENDAR_TIMELINE_SLOT", handleTodoToCalendarTimelineSlot, ); registerHandler("TODO_CARD->TODO_LIST", handleTodoToTodoList); registerHandler("TODO_CARD->TODO_CARD_SLOT", handleTodoToTodoCardSlot); registerHandler("TODO_CARD->TODO_DROP_ZONE", handleTodoToTodoDropZone); registerHandler("PANEL_HEADER->PANEL_HEADER", handlePanelHeaderToPanelHeader); // ============================================================================ // 分发函数 (Dispatch Function) // ============================================================================ /** * 分发拖拽事件到对应的处理器 */ export function dispatchDragDrop( dragData: DragData | undefined, dropData: DropData | undefined, ): DragDropResult { if (!dragData || !dropData) { return { success: false, message: "Missing drag or drop data" }; } const key = `${dragData.type}->${dropData.type}` as HandlerKey; const handler = getHandler(key); if (!handler) { console.warn(`[DnD] No handler registered for: ${key}`); return { success: false, message: `No handler for ${key}` }; } try { const result = handler(dragData, dropData); if (result.success) { console.log(`[DnD] ${key}: ${result.message || "Success"}`); } else { console.warn(`[DnD] ${key} failed: ${result.message}`); } return result; } catch (error) { console.error(`[DnD] Handler error for ${key}:`, error); return { success: false, message: String(error) }; } } ================================================ FILE: free-todo-frontend/lib/dnd/index.ts ================================================ /** * 跨面板拖拽系统入口 * Cross-Panel Drag and Drop System Entry */ // 上下文 export { GlobalDndContext, GlobalDndProvider, useGlobalDnd, useGlobalDndSafe, usePendingUpdate, } from "./context"; // 处理器 export { dispatchDragDrop, getHandler, registerHandler, } from "./handlers"; // 预览组件 export { GlobalDragOverlay } from "./overlays"; // 类型导出 export type { ActiveDragState, DragData, DragDropHandler, DragDropResult, DragSourceType, DropData, DropTargetType, GlobalDndContextValue, HandlerKey, } from "./types"; // 类型守卫 export { isCalendarDateDropData, isTodoCardDragData, isTodoDropZoneDropData, isTodoListDropData, } from "./types"; ================================================ FILE: free-todo-frontend/lib/dnd/overlays.tsx ================================================ "use client"; /** * 全局拖拽预览组件 * Global Drag Overlay Components */ import { DragOverlay } from "@dnd-kit/core"; import { Calendar, Flag, Paperclip, Tag, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { createPortal } from "react-dom"; import type { Todo, TodoPriority, TodoStatus } from "@/lib/types"; import { cn, getPriorityLabel, getStatusLabel } from "@/lib/utils"; import type { ActiveDragState, DragData } from "./types"; // ============================================================================ // 样式辅助函数 // ============================================================================ function getStatusColor(status: TodoStatus) { switch (status) { case "active": return "border-primary/70 bg-primary/20 text-primary"; case "completed": return "border-green-500/60 bg-green-500/12 text-green-600 dark:text-green-500"; case "draft": return "border-orange-500/50 bg-orange-500/8 text-orange-600 dark:text-orange-400"; default: return "border-muted-foreground/40 bg-muted/15 text-muted-foreground"; } } function getPriorityBgColor(priority: TodoPriority) { switch (priority) { case "high": return "border-destructive/60 bg-destructive/10 text-destructive"; case "medium": return "border-primary/60 bg-primary/10 text-primary"; case "low": return "border-secondary/60 bg-secondary/20 text-secondary-foreground"; default: return "border-muted-foreground/40 text-muted-foreground"; } } function formatScheduleLabel(startTime?: string, endTime?: string) { const schedule = startTime ?? endTime; if (!schedule) return null; const startDate = new Date(schedule); if (Number.isNaN(startDate.getTime())) return null; const dateLabel = startDate.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }); const timeLabel = startDate.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", }); const startLabel = startDate.getHours() === 0 && startDate.getMinutes() === 0 ? dateLabel : `${dateLabel} ${timeLabel}`; if (!endTime) return startLabel; const endDate = new Date(endTime); if (Number.isNaN(endDate.getTime())) return startLabel; const sameDay = startDate.toDateString() === endDate.toDateString(); const endDateLabel = endDate.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }); const endTimeLabel = endDate.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", }); const endLabel = sameDay ? endTimeLabel : `${endDateLabel} ${endTimeLabel}`; return `${startLabel} - ${endLabel}`; } // ============================================================================ // Todo 卡片预览组件 // ============================================================================ interface TodoCardOverlayProps { todo: Todo; depth?: number; } function TodoCardOverlay({ todo, depth = 0 }: TodoCardOverlayProps) { const tCommon = useTranslations("common"); const tTodoDetail = useTranslations("todoDetail"); return (
{todo.status === "completed" ? (
) : todo.status === "canceled" ? (
) : todo.status === "draft" ? (
) : (
)}

{todo.name}

{todo.description && (

{todo.description}

)}
{todo.priority && todo.priority !== "none" && (
{getPriorityLabel(todo.priority, tCommon)}
)} {todo.status && ( {getStatusLabel(todo.status, tCommon)} )}
{(todo.startTime || todo.endTime) && (
{formatScheduleLabel(todo.startTime, todo.endTime)}
)} {todo.attachments && todo.attachments.length > 0 && (
{todo.attachments.length}
)} {todo.tags && todo.tags.length > 0 && (
{todo.tags.slice(0, 3).map((tag) => ( {tag} ))} {todo.tags.length > 3 && ( +{todo.tags.length - 3} )}
)}
); } // ============================================================================ // 简化的日历 Todo 预览 // ============================================================================ interface CalendarTodoOverlayProps { todo: Todo; } function CalendarTodoOverlay({ todo }: CalendarTodoOverlayProps) { return (

{todo.name}

{todo.tags && todo.tags.length > 0 && (
{todo.tags.slice(0, 2).map((tag) => ( {tag} ))}
)}
); } // ============================================================================ // 根据拖拽类型渲染预览 // ============================================================================ interface DragOverlayContentProps { data: DragData; } function DragOverlayContent({ data }: DragOverlayContentProps) { switch (data.type) { case "TODO_CARD": { const { todo, depth, sourcePanel } = data.payload; // 根据来源面板决定使用哪种预览样式 if (sourcePanel === "calendar") { return ; } return ; } case "FILE": { return (
{data.payload.file.fileName}
); } case "USER": { return (
{data.payload.userName}
); } case "PANEL_HEADER": { // 不显示拖拽预览 return null; } default: return null; } } // ============================================================================ // 全局拖拽预览组件 // ============================================================================ interface GlobalDragOverlayProps { activeDrag: ActiveDragState | null; } export function GlobalDragOverlay({ activeDrag }: GlobalDragOverlayProps) { // 使用 Portal 渲染到 body,避免父容器 transform 导致的坐标偏移 if (typeof document === "undefined") { return null; } return createPortal( {activeDrag ? : null} , document.body, ); } ================================================ FILE: free-todo-frontend/lib/dnd/types.ts ================================================ /** * 跨面板拖拽系统类型定义 * Cross-Panel Drag and Drop Type Definitions */ import type { UniqueIdentifier } from "@dnd-kit/core"; import type { Todo, TodoAttachment } from "@/lib/types"; // ============================================================================ // 拖拽源类型 (Drag Source Types) // ============================================================================ /** * 可拖拽元素的类型,可扩展 */ export type DragSourceType = "TODO_CARD" | "FILE" | "USER" | "PANEL_HEADER"; /** * 类型安全的拖拽数据 - 使用可辨识联合类型 * Type-safe drag data using discriminated union */ export type DragData = | { type: "TODO_CARD"; payload: { todo: Todo; depth?: number; // 用于侧边栏树形结构的缩进层级 sourcePanel?: string; // 来源面板标识 }; } | { type: "FILE"; payload: { file: TodoAttachment; sourceTodoId?: number; }; } | { type: "USER"; payload: { userId: string; userName: string; }; } | { type: "PANEL_HEADER"; payload: { position: "panelA" | "panelB" | "panelC"; }; }; // ============================================================================ // 放置目标类型 (Drop Target Types) // ============================================================================ /** * 可放置区域的类型,可扩展 */ export type DropTargetType = | "CALENDAR_DATE" | "CALENDAR_TIMELINE_SLOT" | "TODO_LIST" | "TODO_CARD_SLOT" | "TODO_DROP_ZONE" | "CHAT_WINDOW" | "PANEL_HEADER"; /** * 类型安全的放置区数据 - 使用可辨识联合类型 * Type-safe drop data using discriminated union */ export type DropData = | { type: "CALENDAR_DATE"; metadata: { dateKey: string; // 格式: YYYY-MM-DD date: Date; }; } | { type: "CALENDAR_TIMELINE_SLOT"; metadata: { dateKey: string; // 格式: YYYY-MM-DD date: Date; minutes: number; }; } | { type: "TODO_LIST"; metadata: { targetIndex?: number; parentTodoId?: number | null; }; } | { type: "TODO_CARD_SLOT"; metadata: { todoId: number; position: "before" | "after"; }; } | { type: "TODO_DROP_ZONE"; metadata: { todoId: number; position: "nest"; // 设为子任务 }; } | { type: "CHAT_WINDOW"; metadata: { conversationId?: string; }; } | { type: "PANEL_HEADER"; metadata: { position: "panelA" | "panelB" | "panelC"; }; }; // ============================================================================ // 活动拖拽状态 (Active Drag State) // ============================================================================ /** * 当前正在拖拽的元素状态 * id 使用 dnd-kit 的 UniqueIdentifier 类型 (string | number) * 因为不同面板可能使用不同格式的 ID(如 calendar 使用 "calendar-{todoId}") */ export interface ActiveDragState { id: UniqueIdentifier; data: DragData; } // ============================================================================ // 处理器相关类型 (Handler Types) // ============================================================================ /** * 拖拽处理结果 */ export interface DragDropResult { success: boolean; message?: string; } /** * 拖拽处理器的键类型 * 格式: "SOURCE_TYPE->TARGET_TYPE" */ export type HandlerKey = `${DragSourceType}->${DropTargetType}`; /** * 拖拽处理器函数签名 */ export type DragDropHandler = ( dragData: DragData, dropData: DropData, ) => DragDropResult; // ============================================================================ // 上下文类型 (Context Types) // ============================================================================ /** * 全局拖拽上下文值 */ export interface GlobalDndContextValue { activeDrag: ActiveDragState | null; } // ============================================================================ // 类型守卫 (Type Guards) // ============================================================================ /** * 检查是否为 TODO_CARD 类型的拖拽数据 */ export function isTodoCardDragData( data: DragData, ): data is Extract { return data.type === "TODO_CARD"; } /** * 检查是否为 CALENDAR_DATE 类型的放置数据 */ export function isCalendarDateDropData( data: DropData, ): data is Extract { return data.type === "CALENDAR_DATE"; } /** * 检查是否为 TODO_LIST 类型的放置数据 */ export function isTodoListDropData( data: DropData, ): data is Extract { return data.type === "TODO_LIST"; } /** * 检查是否为 TODO_DROP_ZONE 类型的放置数据 */ export function isTodoDropZoneDropData( data: DropData, ): data is Extract { return data.type === "TODO_DROP_ZONE"; } ================================================ FILE: free-todo-frontend/lib/generated/activity/activity.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { ActivityEventsResponse, ActivityListResponse, HTTPValidationError, ListActivitiesApiActivitiesGetParams, ManualActivityCreateRequest, ManualActivityCreateResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取活动列表(活动=聚合的事件窗口) * @summary List Activities */ export type listActivitiesApiActivitiesGetResponse200 = { data: ActivityListResponse status: 200 } export type listActivitiesApiActivitiesGetResponse422 = { data: HTTPValidationError status: 422 } export type listActivitiesApiActivitiesGetResponseSuccess = (listActivitiesApiActivitiesGetResponse200) & { headers: Headers; }; export type listActivitiesApiActivitiesGetResponseError = (listActivitiesApiActivitiesGetResponse422) & { headers: Headers; }; export type listActivitiesApiActivitiesGetResponse = (listActivitiesApiActivitiesGetResponseSuccess | listActivitiesApiActivitiesGetResponseError) export const getListActivitiesApiActivitiesGetUrl = (params?: ListActivitiesApiActivitiesGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/activities?${stringifiedParams}` : `/api/activities` } export const listActivitiesApiActivitiesGet = async (params?: ListActivitiesApiActivitiesGetParams, options?: RequestInit): Promise => { return customFetcher(getListActivitiesApiActivitiesGetUrl(params), { ...options, method: 'GET' } );} export const getListActivitiesApiActivitiesGetQueryKey = (params?: ListActivitiesApiActivitiesGetParams,) => { return [ `/api/activities`, ...(params ? [params] : []) ] as const; } export const getListActivitiesApiActivitiesGetQueryOptions = >, TError = HTTPValidationError>(params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListActivitiesApiActivitiesGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => listActivitiesApiActivitiesGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type ListActivitiesApiActivitiesGetQueryResult = NonNullable>> export type ListActivitiesApiActivitiesGetQueryError = HTTPValidationError export function useListActivitiesApiActivitiesGet>, TError = HTTPValidationError>( params: undefined | ListActivitiesApiActivitiesGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useListActivitiesApiActivitiesGet>, TError = HTTPValidationError>( params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useListActivitiesApiActivitiesGet>, TError = HTTPValidationError>( params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary List Activities */ export function useListActivitiesApiActivitiesGet>, TError = HTTPValidationError>( params?: ListActivitiesApiActivitiesGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getListActivitiesApiActivitiesGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取指定活动关联的事件ID列表 * @summary Get Activity Events */ export type getActivityEventsApiActivitiesActivityIdEventsGetResponse200 = { data: ActivityEventsResponse status: 200 } export type getActivityEventsApiActivitiesActivityIdEventsGetResponse422 = { data: HTTPValidationError status: 422 } export type getActivityEventsApiActivitiesActivityIdEventsGetResponseSuccess = (getActivityEventsApiActivitiesActivityIdEventsGetResponse200) & { headers: Headers; }; export type getActivityEventsApiActivitiesActivityIdEventsGetResponseError = (getActivityEventsApiActivitiesActivityIdEventsGetResponse422) & { headers: Headers; }; export type getActivityEventsApiActivitiesActivityIdEventsGetResponse = (getActivityEventsApiActivitiesActivityIdEventsGetResponseSuccess | getActivityEventsApiActivitiesActivityIdEventsGetResponseError) export const getGetActivityEventsApiActivitiesActivityIdEventsGetUrl = (activityId: number,) => { return `/api/activities/${activityId}/events` } export const getActivityEventsApiActivitiesActivityIdEventsGet = async (activityId: number, options?: RequestInit): Promise => { return customFetcher(getGetActivityEventsApiActivitiesActivityIdEventsGetUrl(activityId), { ...options, method: 'GET' } );} export const getGetActivityEventsApiActivitiesActivityIdEventsGetQueryKey = (activityId: number,) => { return [ `/api/activities/${activityId}/events` ] as const; } export const getGetActivityEventsApiActivitiesActivityIdEventsGetQueryOptions = >, TError = HTTPValidationError>(activityId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetActivityEventsApiActivitiesActivityIdEventsGetQueryKey(activityId); const queryFn: QueryFunction>> = ({ signal }) => getActivityEventsApiActivitiesActivityIdEventsGet(activityId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(activityId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetActivityEventsApiActivitiesActivityIdEventsGetQueryResult = NonNullable>> export type GetActivityEventsApiActivitiesActivityIdEventsGetQueryError = HTTPValidationError export function useGetActivityEventsApiActivitiesActivityIdEventsGet>, TError = HTTPValidationError>( activityId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetActivityEventsApiActivitiesActivityIdEventsGet>, TError = HTTPValidationError>( activityId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetActivityEventsApiActivitiesActivityIdEventsGet>, TError = HTTPValidationError>( activityId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Activity Events */ export function useGetActivityEventsApiActivitiesActivityIdEventsGet>, TError = HTTPValidationError>( activityId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetActivityEventsApiActivitiesActivityIdEventsGetQueryOptions(activityId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 手动聚合指定事件集合为活动 Args: request: 包含事件ID列表的请求 Returns: 创建的活动信息 * @summary Create Activity Manual */ export type createActivityManualApiActivitiesManualPostResponse201 = { data: ManualActivityCreateResponse status: 201 } export type createActivityManualApiActivitiesManualPostResponse422 = { data: HTTPValidationError status: 422 } export type createActivityManualApiActivitiesManualPostResponseSuccess = (createActivityManualApiActivitiesManualPostResponse201) & { headers: Headers; }; export type createActivityManualApiActivitiesManualPostResponseError = (createActivityManualApiActivitiesManualPostResponse422) & { headers: Headers; }; export type createActivityManualApiActivitiesManualPostResponse = (createActivityManualApiActivitiesManualPostResponseSuccess | createActivityManualApiActivitiesManualPostResponseError) export const getCreateActivityManualApiActivitiesManualPostUrl = () => { return `/api/activities/manual` } export const createActivityManualApiActivitiesManualPost = async (manualActivityCreateRequest: ManualActivityCreateRequest, options?: RequestInit): Promise => { return customFetcher(getCreateActivityManualApiActivitiesManualPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( manualActivityCreateRequest,) } );} export const getCreateActivityManualApiActivitiesManualPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ManualActivityCreateRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: ManualActivityCreateRequest}, TContext> => { const mutationKey = ['createActivityManualApiActivitiesManualPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: ManualActivityCreateRequest}> = (props) => { const {data} = props ?? {}; return createActivityManualApiActivitiesManualPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type CreateActivityManualApiActivitiesManualPostMutationResult = NonNullable>> export type CreateActivityManualApiActivitiesManualPostMutationBody = ManualActivityCreateRequest export type CreateActivityManualApiActivitiesManualPostMutationError = HTTPValidationError /** * @summary Create Activity Manual */ export const useCreateActivityManualApiActivitiesManualPost = (options?: { mutation?:UseMutationOptions>, TError,{data: ManualActivityCreateRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: ManualActivityCreateRequest}, TContext > => { return useMutation(getCreateActivityManualApiActivitiesManualPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/audio/audio.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { AudioLinkRequest, ExtractTodosAndSchedulesApiAudioExtractPostParams, GetRecordingsApiAudioRecordingsGetParams, GetTimelineApiAudioTimelineGetParams, GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, HTTPValidationError, LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams, OptimizeTranscriptionApiAudioOptimizePostParams } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取录音列表 * @summary Get Recordings */ export type getRecordingsApiAudioRecordingsGetResponse200 = { data: unknown status: 200 } export type getRecordingsApiAudioRecordingsGetResponse422 = { data: HTTPValidationError status: 422 } export type getRecordingsApiAudioRecordingsGetResponseSuccess = (getRecordingsApiAudioRecordingsGetResponse200) & { headers: Headers; }; export type getRecordingsApiAudioRecordingsGetResponseError = (getRecordingsApiAudioRecordingsGetResponse422) & { headers: Headers; }; export type getRecordingsApiAudioRecordingsGetResponse = (getRecordingsApiAudioRecordingsGetResponseSuccess | getRecordingsApiAudioRecordingsGetResponseError) export const getGetRecordingsApiAudioRecordingsGetUrl = (params?: GetRecordingsApiAudioRecordingsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/audio/recordings?${stringifiedParams}` : `/api/audio/recordings` } export const getRecordingsApiAudioRecordingsGet = async (params?: GetRecordingsApiAudioRecordingsGetParams, options?: RequestInit): Promise => { return customFetcher(getGetRecordingsApiAudioRecordingsGetUrl(params), { ...options, method: 'GET' } );} export const getGetRecordingsApiAudioRecordingsGetQueryKey = (params?: GetRecordingsApiAudioRecordingsGetParams,) => { return [ `/api/audio/recordings`, ...(params ? [params] : []) ] as const; } export const getGetRecordingsApiAudioRecordingsGetQueryOptions = >, TError = HTTPValidationError>(params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetRecordingsApiAudioRecordingsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getRecordingsApiAudioRecordingsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetRecordingsApiAudioRecordingsGetQueryResult = NonNullable>> export type GetRecordingsApiAudioRecordingsGetQueryError = HTTPValidationError export function useGetRecordingsApiAudioRecordingsGet>, TError = HTTPValidationError>( params: undefined | GetRecordingsApiAudioRecordingsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetRecordingsApiAudioRecordingsGet>, TError = HTTPValidationError>( params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetRecordingsApiAudioRecordingsGet>, TError = HTTPValidationError>( params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Recordings */ export function useGetRecordingsApiAudioRecordingsGet>, TError = HTTPValidationError>( params?: GetRecordingsApiAudioRecordingsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetRecordingsApiAudioRecordingsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 按日期返回录音时间线(含转录文本) * @summary Get Timeline */ export type getTimelineApiAudioTimelineGetResponse200 = { data: unknown status: 200 } export type getTimelineApiAudioTimelineGetResponse422 = { data: HTTPValidationError status: 422 } export type getTimelineApiAudioTimelineGetResponseSuccess = (getTimelineApiAudioTimelineGetResponse200) & { headers: Headers; }; export type getTimelineApiAudioTimelineGetResponseError = (getTimelineApiAudioTimelineGetResponse422) & { headers: Headers; }; export type getTimelineApiAudioTimelineGetResponse = (getTimelineApiAudioTimelineGetResponseSuccess | getTimelineApiAudioTimelineGetResponseError) export const getGetTimelineApiAudioTimelineGetUrl = (params?: GetTimelineApiAudioTimelineGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/audio/timeline?${stringifiedParams}` : `/api/audio/timeline` } export const getTimelineApiAudioTimelineGet = async (params?: GetTimelineApiAudioTimelineGetParams, options?: RequestInit): Promise => { return customFetcher(getGetTimelineApiAudioTimelineGetUrl(params), { ...options, method: 'GET' } );} export const getGetTimelineApiAudioTimelineGetQueryKey = (params?: GetTimelineApiAudioTimelineGetParams,) => { return [ `/api/audio/timeline`, ...(params ? [params] : []) ] as const; } export const getGetTimelineApiAudioTimelineGetQueryOptions = >, TError = HTTPValidationError>(params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetTimelineApiAudioTimelineGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getTimelineApiAudioTimelineGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetTimelineApiAudioTimelineGetQueryResult = NonNullable>> export type GetTimelineApiAudioTimelineGetQueryError = HTTPValidationError export function useGetTimelineApiAudioTimelineGet>, TError = HTTPValidationError>( params: undefined | GetTimelineApiAudioTimelineGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetTimelineApiAudioTimelineGet>, TError = HTTPValidationError>( params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetTimelineApiAudioTimelineGet>, TError = HTTPValidationError>( params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Timeline */ export function useGetTimelineApiAudioTimelineGet>, TError = HTTPValidationError>( params?: GetTimelineApiAudioTimelineGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetTimelineApiAudioTimelineGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取录音文件(用于前端播放) * @summary Get Recording File */ export type getRecordingFileApiAudioRecordingRecordingIdFileGetResponse200 = { data: unknown status: 200 } export type getRecordingFileApiAudioRecordingRecordingIdFileGetResponse422 = { data: HTTPValidationError status: 422 } export type getRecordingFileApiAudioRecordingRecordingIdFileGetResponseSuccess = (getRecordingFileApiAudioRecordingRecordingIdFileGetResponse200) & { headers: Headers; }; export type getRecordingFileApiAudioRecordingRecordingIdFileGetResponseError = (getRecordingFileApiAudioRecordingRecordingIdFileGetResponse422) & { headers: Headers; }; export type getRecordingFileApiAudioRecordingRecordingIdFileGetResponse = (getRecordingFileApiAudioRecordingRecordingIdFileGetResponseSuccess | getRecordingFileApiAudioRecordingRecordingIdFileGetResponseError) export const getGetRecordingFileApiAudioRecordingRecordingIdFileGetUrl = (recordingId: number,) => { return `/api/audio/recording/${recordingId}/file` } export const getRecordingFileApiAudioRecordingRecordingIdFileGet = async (recordingId: number, options?: RequestInit): Promise => { return customFetcher(getGetRecordingFileApiAudioRecordingRecordingIdFileGetUrl(recordingId), { ...options, method: 'GET' } );} export const getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryKey = (recordingId: number,) => { return [ `/api/audio/recording/${recordingId}/file` ] as const; } export const getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryOptions = >, TError = HTTPValidationError>(recordingId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryKey(recordingId); const queryFn: QueryFunction>> = ({ signal }) => getRecordingFileApiAudioRecordingRecordingIdFileGet(recordingId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(recordingId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetRecordingFileApiAudioRecordingRecordingIdFileGetQueryResult = NonNullable>> export type GetRecordingFileApiAudioRecordingRecordingIdFileGetQueryError = HTTPValidationError export function useGetRecordingFileApiAudioRecordingRecordingIdFileGet>, TError = HTTPValidationError>( recordingId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetRecordingFileApiAudioRecordingRecordingIdFileGet>, TError = HTTPValidationError>( recordingId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetRecordingFileApiAudioRecordingRecordingIdFileGet>, TError = HTTPValidationError>( recordingId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Recording File */ export function useGetRecordingFileApiAudioRecordingRecordingIdFileGet>, TError = HTTPValidationError>( recordingId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetRecordingFileApiAudioRecordingRecordingIdFileGetQueryOptions(recordingId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取转录文本 * @summary Get Transcription */ export type getTranscriptionApiAudioTranscriptionRecordingIdGetResponse200 = { data: unknown status: 200 } export type getTranscriptionApiAudioTranscriptionRecordingIdGetResponse422 = { data: HTTPValidationError status: 422 } export type getTranscriptionApiAudioTranscriptionRecordingIdGetResponseSuccess = (getTranscriptionApiAudioTranscriptionRecordingIdGetResponse200) & { headers: Headers; }; export type getTranscriptionApiAudioTranscriptionRecordingIdGetResponseError = (getTranscriptionApiAudioTranscriptionRecordingIdGetResponse422) & { headers: Headers; }; export type getTranscriptionApiAudioTranscriptionRecordingIdGetResponse = (getTranscriptionApiAudioTranscriptionRecordingIdGetResponseSuccess | getTranscriptionApiAudioTranscriptionRecordingIdGetResponseError) export const getGetTranscriptionApiAudioTranscriptionRecordingIdGetUrl = (recordingId: number, params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/audio/transcription/${recordingId}?${stringifiedParams}` : `/api/audio/transcription/${recordingId}` } export const getTranscriptionApiAudioTranscriptionRecordingIdGet = async (recordingId: number, params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: RequestInit): Promise => { return customFetcher(getGetTranscriptionApiAudioTranscriptionRecordingIdGetUrl(recordingId,params), { ...options, method: 'GET' } );} export const getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryKey = (recordingId: number, params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams,) => { return [ `/api/audio/transcription/${recordingId}`, ...(params ? [params] : []) ] as const; } export const getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryOptions = >, TError = HTTPValidationError>(recordingId: number, params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryKey(recordingId,params); const queryFn: QueryFunction>> = ({ signal }) => getTranscriptionApiAudioTranscriptionRecordingIdGet(recordingId,params, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(recordingId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetTranscriptionApiAudioTranscriptionRecordingIdGetQueryResult = NonNullable>> export type GetTranscriptionApiAudioTranscriptionRecordingIdGetQueryError = HTTPValidationError export function useGetTranscriptionApiAudioTranscriptionRecordingIdGet>, TError = HTTPValidationError>( recordingId: number, params: undefined | GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetTranscriptionApiAudioTranscriptionRecordingIdGet>, TError = HTTPValidationError>( recordingId: number, params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetTranscriptionApiAudioTranscriptionRecordingIdGet>, TError = HTTPValidationError>( recordingId: number, params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Transcription */ export function useGetTranscriptionApiAudioTranscriptionRecordingIdGet>, TError = HTTPValidationError>( recordingId: number, params?: GetTranscriptionApiAudioTranscriptionRecordingIdGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetTranscriptionApiAudioTranscriptionRecordingIdGetQueryOptions(recordingId,params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * Mark extracted items as linked to todos (persisted in transcription JSON). Args: recording_id: 录音ID request: 链接请求 optimized: 是否更新优化文本的提取结果 * @summary Link Extracted Items */ export type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse200 = { data: unknown status: 200 } export type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse422 = { data: HTTPValidationError status: 422 } export type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseSuccess = (linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse200) & { headers: Headers; }; export type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseError = (linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse422) & { headers: Headers; }; export type linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponse = (linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseSuccess | linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostResponseError) export const getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostUrl = (recordingId: number, params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/audio/transcription/${recordingId}/link?${stringifiedParams}` : `/api/audio/transcription/${recordingId}/link` } export const linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost = async (recordingId: number, audioLinkRequest: AudioLinkRequest, params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams, options?: RequestInit): Promise => { return customFetcher(getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostUrl(recordingId,params), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( audioLinkRequest,) } );} export const getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}, TContext> => { const mutationKey = ['linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}> = (props) => { const {recordingId,data,params} = props ?? {}; return linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost(recordingId,data,params,requestOptions) } return { mutationFn, ...mutationOptions }} export type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationResult = NonNullable>> export type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationBody = AudioLinkRequest export type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationError = HTTPValidationError /** * @summary Link Extracted Items */ export const useLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPost = (options?: { mutation?:UseMutationOptions>, TError,{recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {recordingId: number;data: AudioLinkRequest;params?: LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams}, TContext > => { return useMutation(getLinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostMutationOptions(options), queryClient); } /** * 优化转录文本(使用LLM) * @summary Optimize Transcription */ export type optimizeTranscriptionApiAudioOptimizePostResponse200 = { data: unknown status: 200 } export type optimizeTranscriptionApiAudioOptimizePostResponse422 = { data: HTTPValidationError status: 422 } export type optimizeTranscriptionApiAudioOptimizePostResponseSuccess = (optimizeTranscriptionApiAudioOptimizePostResponse200) & { headers: Headers; }; export type optimizeTranscriptionApiAudioOptimizePostResponseError = (optimizeTranscriptionApiAudioOptimizePostResponse422) & { headers: Headers; }; export type optimizeTranscriptionApiAudioOptimizePostResponse = (optimizeTranscriptionApiAudioOptimizePostResponseSuccess | optimizeTranscriptionApiAudioOptimizePostResponseError) export const getOptimizeTranscriptionApiAudioOptimizePostUrl = (params: OptimizeTranscriptionApiAudioOptimizePostParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/audio/optimize?${stringifiedParams}` : `/api/audio/optimize` } export const optimizeTranscriptionApiAudioOptimizePost = async (params: OptimizeTranscriptionApiAudioOptimizePostParams, options?: RequestInit): Promise => { return customFetcher(getOptimizeTranscriptionApiAudioOptimizePostUrl(params), { ...options, method: 'POST' } );} export const getOptimizeTranscriptionApiAudioOptimizePostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{params: OptimizeTranscriptionApiAudioOptimizePostParams}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{params: OptimizeTranscriptionApiAudioOptimizePostParams}, TContext> => { const mutationKey = ['optimizeTranscriptionApiAudioOptimizePost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {params: OptimizeTranscriptionApiAudioOptimizePostParams}> = (props) => { const {params} = props ?? {}; return optimizeTranscriptionApiAudioOptimizePost(params,requestOptions) } return { mutationFn, ...mutationOptions }} export type OptimizeTranscriptionApiAudioOptimizePostMutationResult = NonNullable>> export type OptimizeTranscriptionApiAudioOptimizePostMutationError = HTTPValidationError /** * @summary Optimize Transcription */ export const useOptimizeTranscriptionApiAudioOptimizePost = (options?: { mutation?:UseMutationOptions>, TError,{params: OptimizeTranscriptionApiAudioOptimizePostParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {params: OptimizeTranscriptionApiAudioOptimizePostParams}, TContext > => { return useMutation(getOptimizeTranscriptionApiAudioOptimizePostMutationOptions(options), queryClient); } /** * 提取待办事项和日程安排 Args: recording_id: 录音ID optimized: 是否从优化文本提取(False=从原文提取) * @summary Extract Todos And Schedules */ export type extractTodosAndSchedulesApiAudioExtractPostResponse200 = { data: unknown status: 200 } export type extractTodosAndSchedulesApiAudioExtractPostResponse422 = { data: HTTPValidationError status: 422 } export type extractTodosAndSchedulesApiAudioExtractPostResponseSuccess = (extractTodosAndSchedulesApiAudioExtractPostResponse200) & { headers: Headers; }; export type extractTodosAndSchedulesApiAudioExtractPostResponseError = (extractTodosAndSchedulesApiAudioExtractPostResponse422) & { headers: Headers; }; export type extractTodosAndSchedulesApiAudioExtractPostResponse = (extractTodosAndSchedulesApiAudioExtractPostResponseSuccess | extractTodosAndSchedulesApiAudioExtractPostResponseError) export const getExtractTodosAndSchedulesApiAudioExtractPostUrl = (params: ExtractTodosAndSchedulesApiAudioExtractPostParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/audio/extract?${stringifiedParams}` : `/api/audio/extract` } export const extractTodosAndSchedulesApiAudioExtractPost = async (params: ExtractTodosAndSchedulesApiAudioExtractPostParams, options?: RequestInit): Promise => { return customFetcher(getExtractTodosAndSchedulesApiAudioExtractPostUrl(params), { ...options, method: 'POST' } );} export const getExtractTodosAndSchedulesApiAudioExtractPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{params: ExtractTodosAndSchedulesApiAudioExtractPostParams}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{params: ExtractTodosAndSchedulesApiAudioExtractPostParams}, TContext> => { const mutationKey = ['extractTodosAndSchedulesApiAudioExtractPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {params: ExtractTodosAndSchedulesApiAudioExtractPostParams}> = (props) => { const {params} = props ?? {}; return extractTodosAndSchedulesApiAudioExtractPost(params,requestOptions) } return { mutationFn, ...mutationOptions }} export type ExtractTodosAndSchedulesApiAudioExtractPostMutationResult = NonNullable>> export type ExtractTodosAndSchedulesApiAudioExtractPostMutationError = HTTPValidationError /** * @summary Extract Todos And Schedules */ export const useExtractTodosAndSchedulesApiAudioExtractPost = (options?: { mutation?:UseMutationOptions>, TError,{params: ExtractTodosAndSchedulesApiAudioExtractPostParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {params: ExtractTodosAndSchedulesApiAudioExtractPostParams}, TContext > => { return useMutation(getExtractTodosAndSchedulesApiAudioExtractPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/case-transform.ts ================================================ /** * snake_case <-> camelCase conversion utilities * Used by customFetcher to auto-transform API request/response keys */ /** * Convert snake_case string to camelCase * @example "user_notes" -> "userNotes" */ export function toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); } /** * Convert camelCase string to snake_case * @example "userNotes" -> "user_notes" */ export function toSnakeCase(str: string): string { return str.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); } /** * Recursively transform all keys of an object using the provided transformer function * Handles nested objects, arrays, and primitive values */ export function transformKeys( obj: unknown, transformer: (key: string) => string, ): T { if (obj === null || obj === undefined) return obj as T; if (Array.isArray(obj)) { return obj.map((item) => transformKeys(item, transformer)) as T; } if (typeof obj === "object" && obj instanceof Date) { return obj as T; } if (typeof obj === "object") { return Object.fromEntries( Object.entries(obj as Record).map(([k, v]) => [ transformer(k), transformKeys(v, transformer), ]), ) as T; } return obj as T; } /** * Convert all keys from snake_case to camelCase * Used for API response transformation */ export const snakeToCamel = (obj: unknown): T => transformKeys(obj, toCamelCase); /** * Convert all keys from camelCase to snake_case * Used for API request transformation */ export const camelToSnake = (obj: unknown): T => transformKeys(obj, toSnakeCase); ================================================ FILE: free-todo-frontend/lib/generated/chat/chat.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { AddMessageRequest, ChatMessage, ChatMessageWithContext, ChatResponse, GetChatHistoryApiChatHistoryGetParams, GetQuerySuggestionsApiChatSuggestionsGetParams, HTTPValidationError, MessageTodoExtractionRequest, MessageTodoExtractionResponse, NewChatRequest, NewChatResponse, PlanQuestionnaireRequest, PlanSummaryRequest } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 带事件上下文的流式聊天接口 * @summary Chat With Context Stream */ export type chatWithContextStreamApiChatStreamWithContextPostResponse200 = { data: unknown status: 200 } export type chatWithContextStreamApiChatStreamWithContextPostResponse422 = { data: HTTPValidationError status: 422 } export type chatWithContextStreamApiChatStreamWithContextPostResponseSuccess = (chatWithContextStreamApiChatStreamWithContextPostResponse200) & { headers: Headers; }; export type chatWithContextStreamApiChatStreamWithContextPostResponseError = (chatWithContextStreamApiChatStreamWithContextPostResponse422) & { headers: Headers; }; export type chatWithContextStreamApiChatStreamWithContextPostResponse = (chatWithContextStreamApiChatStreamWithContextPostResponseSuccess | chatWithContextStreamApiChatStreamWithContextPostResponseError) export const getChatWithContextStreamApiChatStreamWithContextPostUrl = () => { return `/api/chat/stream-with-context` } export const chatWithContextStreamApiChatStreamWithContextPost = async (chatMessageWithContext: ChatMessageWithContext, options?: RequestInit): Promise => { return customFetcher(getChatWithContextStreamApiChatStreamWithContextPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( chatMessageWithContext,) } );} export const getChatWithContextStreamApiChatStreamWithContextPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ChatMessageWithContext}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: ChatMessageWithContext}, TContext> => { const mutationKey = ['chatWithContextStreamApiChatStreamWithContextPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: ChatMessageWithContext}> = (props) => { const {data} = props ?? {}; return chatWithContextStreamApiChatStreamWithContextPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ChatWithContextStreamApiChatStreamWithContextPostMutationResult = NonNullable>> export type ChatWithContextStreamApiChatStreamWithContextPostMutationBody = ChatMessageWithContext export type ChatWithContextStreamApiChatStreamWithContextPostMutationError = HTTPValidationError /** * @summary Chat With Context Stream */ export const useChatWithContextStreamApiChatStreamWithContextPost = (options?: { mutation?:UseMutationOptions>, TError,{data: ChatMessageWithContext}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: ChatMessageWithContext}, TContext > => { return useMutation(getChatWithContextStreamApiChatStreamWithContextPostMutationOptions(options), queryClient); } /** * 与LLM聊天接口 - 集成RAG功能 * @summary Chat With Llm */ export type chatWithLlmApiChatPostResponse200 = { data: ChatResponse status: 200 } export type chatWithLlmApiChatPostResponse422 = { data: HTTPValidationError status: 422 } export type chatWithLlmApiChatPostResponseSuccess = (chatWithLlmApiChatPostResponse200) & { headers: Headers; }; export type chatWithLlmApiChatPostResponseError = (chatWithLlmApiChatPostResponse422) & { headers: Headers; }; export type chatWithLlmApiChatPostResponse = (chatWithLlmApiChatPostResponseSuccess | chatWithLlmApiChatPostResponseError) export const getChatWithLlmApiChatPostUrl = () => { return `/api/chat` } export const chatWithLlmApiChatPost = async (chatMessage: ChatMessage, options?: RequestInit): Promise => { return customFetcher(getChatWithLlmApiChatPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( chatMessage,) } );} export const getChatWithLlmApiChatPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: ChatMessage}, TContext> => { const mutationKey = ['chatWithLlmApiChatPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: ChatMessage}> = (props) => { const {data} = props ?? {}; return chatWithLlmApiChatPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ChatWithLlmApiChatPostMutationResult = NonNullable>> export type ChatWithLlmApiChatPostMutationBody = ChatMessage export type ChatWithLlmApiChatPostMutationError = HTTPValidationError /** * @summary Chat With Llm */ export const useChatWithLlmApiChatPost = (options?: { mutation?:UseMutationOptions>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: ChatMessage}, TContext > => { return useMutation(getChatWithLlmApiChatPostMutationOptions(options), queryClient); } /** * 与LLM聊天接口(流式输出) 支持额外的 mode 字段: - 默认为现有行为(走本地 LLM + RAG) - 当 mode == "dify_test" 时,走 Dify 测试通道 - 当 mode == "agno" 时,走 Agno Agent 通道(支持 file/shell 等外部工具) * @summary Chat With Llm Stream */ export type chatWithLlmStreamApiChatStreamPostResponse200 = { data: unknown status: 200 } export type chatWithLlmStreamApiChatStreamPostResponse422 = { data: HTTPValidationError status: 422 } export type chatWithLlmStreamApiChatStreamPostResponseSuccess = (chatWithLlmStreamApiChatStreamPostResponse200) & { headers: Headers; }; export type chatWithLlmStreamApiChatStreamPostResponseError = (chatWithLlmStreamApiChatStreamPostResponse422) & { headers: Headers; }; export type chatWithLlmStreamApiChatStreamPostResponse = (chatWithLlmStreamApiChatStreamPostResponseSuccess | chatWithLlmStreamApiChatStreamPostResponseError) export const getChatWithLlmStreamApiChatStreamPostUrl = () => { return `/api/chat/stream` } export const chatWithLlmStreamApiChatStreamPost = async (chatMessage: ChatMessage, options?: RequestInit): Promise => { return customFetcher(getChatWithLlmStreamApiChatStreamPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( chatMessage,) } );} export const getChatWithLlmStreamApiChatStreamPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: ChatMessage}, TContext> => { const mutationKey = ['chatWithLlmStreamApiChatStreamPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: ChatMessage}> = (props) => { const {data} = props ?? {}; return chatWithLlmStreamApiChatStreamPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ChatWithLlmStreamApiChatStreamPostMutationResult = NonNullable>> export type ChatWithLlmStreamApiChatStreamPostMutationBody = ChatMessage export type ChatWithLlmStreamApiChatStreamPostMutationError = HTTPValidationError /** * @summary Chat With Llm Stream */ export const useChatWithLlmStreamApiChatStreamPost = (options?: { mutation?:UseMutationOptions>, TError,{data: ChatMessage}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: ChatMessage}, TContext > => { return useMutation(getChatWithLlmStreamApiChatStreamPostMutationOptions(options), queryClient); } /** * 从消息中提取待办事项 Args: request: 包含消息列表、父待办ID和待办上下文的请求 Returns: 提取的待办列表 Raises: HTTPException: 当提取失败时 * @summary Extract Todos From Messages */ export type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse200 = { data: MessageTodoExtractionResponse status: 200 } export type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse422 = { data: HTTPValidationError status: 422 } export type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseSuccess = (extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse200) & { headers: Headers; }; export type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseError = (extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse422) & { headers: Headers; }; export type extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponse = (extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseSuccess | extractTodosFromMessagesApiChatExtractTodosFromMessagesPostResponseError) export const getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostUrl = () => { return `/api/chat/extract-todos-from-messages` } export const extractTodosFromMessagesApiChatExtractTodosFromMessagesPost = async (messageTodoExtractionRequest: MessageTodoExtractionRequest, options?: RequestInit): Promise => { return customFetcher(getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( messageTodoExtractionRequest,) } );} export const getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: MessageTodoExtractionRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: MessageTodoExtractionRequest}, TContext> => { const mutationKey = ['extractTodosFromMessagesApiChatExtractTodosFromMessagesPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: MessageTodoExtractionRequest}> = (props) => { const {data} = props ?? {}; return extractTodosFromMessagesApiChatExtractTodosFromMessagesPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationResult = NonNullable>> export type ExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationBody = MessageTodoExtractionRequest export type ExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationError = HTTPValidationError /** * @summary Extract Todos From Messages */ export const useExtractTodosFromMessagesApiChatExtractTodosFromMessagesPost = (options?: { mutation?:UseMutationOptions>, TError,{data: MessageTodoExtractionRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: MessageTodoExtractionRequest}, TContext > => { return useMutation(getExtractTodosFromMessagesApiChatExtractTodosFromMessagesPostMutationOptions(options), queryClient); } /** * 创建新对话会话 * @summary Create New Chat */ export type createNewChatApiChatNewPostResponse200 = { data: NewChatResponse status: 200 } export type createNewChatApiChatNewPostResponse422 = { data: HTTPValidationError status: 422 } export type createNewChatApiChatNewPostResponseSuccess = (createNewChatApiChatNewPostResponse200) & { headers: Headers; }; export type createNewChatApiChatNewPostResponseError = (createNewChatApiChatNewPostResponse422) & { headers: Headers; }; export type createNewChatApiChatNewPostResponse = (createNewChatApiChatNewPostResponseSuccess | createNewChatApiChatNewPostResponseError) export const getCreateNewChatApiChatNewPostUrl = () => { return `/api/chat/new` } export const createNewChatApiChatNewPost = async (newChatRequestNull: NewChatRequest | null, options?: RequestInit): Promise => { return customFetcher(getCreateNewChatApiChatNewPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( newChatRequestNull,) } );} export const getCreateNewChatApiChatNewPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: NewChatRequest | null}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: NewChatRequest | null}, TContext> => { const mutationKey = ['createNewChatApiChatNewPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: NewChatRequest | null}> = (props) => { const {data} = props ?? {}; return createNewChatApiChatNewPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type CreateNewChatApiChatNewPostMutationResult = NonNullable>> export type CreateNewChatApiChatNewPostMutationBody = NewChatRequest | null export type CreateNewChatApiChatNewPostMutationError = HTTPValidationError /** * @summary Create New Chat */ export const useCreateNewChatApiChatNewPost = (options?: { mutation?:UseMutationOptions>, TError,{data: NewChatRequest | null}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: NewChatRequest | null}, TContext > => { return useMutation(getCreateNewChatApiChatNewPostMutationOptions(options), queryClient); } /** * 添加消息到会话(消息已在流式聊天中自动保存,此接口保持兼容性) * @summary Add Message To Session */ export type addMessageToSessionApiChatSessionSessionIdMessagePostResponse200 = { data: unknown status: 200 } export type addMessageToSessionApiChatSessionSessionIdMessagePostResponse422 = { data: HTTPValidationError status: 422 } export type addMessageToSessionApiChatSessionSessionIdMessagePostResponseSuccess = (addMessageToSessionApiChatSessionSessionIdMessagePostResponse200) & { headers: Headers; }; export type addMessageToSessionApiChatSessionSessionIdMessagePostResponseError = (addMessageToSessionApiChatSessionSessionIdMessagePostResponse422) & { headers: Headers; }; export type addMessageToSessionApiChatSessionSessionIdMessagePostResponse = (addMessageToSessionApiChatSessionSessionIdMessagePostResponseSuccess | addMessageToSessionApiChatSessionSessionIdMessagePostResponseError) export const getAddMessageToSessionApiChatSessionSessionIdMessagePostUrl = (sessionId: string,) => { return `/api/chat/session/${sessionId}/message` } export const addMessageToSessionApiChatSessionSessionIdMessagePost = async (sessionId: string, addMessageRequest: AddMessageRequest, options?: RequestInit): Promise => { return customFetcher(getAddMessageToSessionApiChatSessionSessionIdMessagePostUrl(sessionId), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( addMessageRequest,) } );} export const getAddMessageToSessionApiChatSessionSessionIdMessagePostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string;data: AddMessageRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{sessionId: string;data: AddMessageRequest}, TContext> => { const mutationKey = ['addMessageToSessionApiChatSessionSessionIdMessagePost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {sessionId: string;data: AddMessageRequest}> = (props) => { const {sessionId,data} = props ?? {}; return addMessageToSessionApiChatSessionSessionIdMessagePost(sessionId,data,requestOptions) } return { mutationFn, ...mutationOptions }} export type AddMessageToSessionApiChatSessionSessionIdMessagePostMutationResult = NonNullable>> export type AddMessageToSessionApiChatSessionSessionIdMessagePostMutationBody = AddMessageRequest export type AddMessageToSessionApiChatSessionSessionIdMessagePostMutationError = HTTPValidationError /** * @summary Add Message To Session */ export const useAddMessageToSessionApiChatSessionSessionIdMessagePost = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string;data: AddMessageRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {sessionId: string;data: AddMessageRequest}, TContext > => { return useMutation(getAddMessageToSessionApiChatSessionSessionIdMessagePostMutationOptions(options), queryClient); } /** * 清除指定会话的上下文 * @summary Clear Chat Session */ export type clearChatSessionApiChatSessionSessionIdDeleteResponse200 = { data: unknown status: 200 } export type clearChatSessionApiChatSessionSessionIdDeleteResponse422 = { data: HTTPValidationError status: 422 } export type clearChatSessionApiChatSessionSessionIdDeleteResponseSuccess = (clearChatSessionApiChatSessionSessionIdDeleteResponse200) & { headers: Headers; }; export type clearChatSessionApiChatSessionSessionIdDeleteResponseError = (clearChatSessionApiChatSessionSessionIdDeleteResponse422) & { headers: Headers; }; export type clearChatSessionApiChatSessionSessionIdDeleteResponse = (clearChatSessionApiChatSessionSessionIdDeleteResponseSuccess | clearChatSessionApiChatSessionSessionIdDeleteResponseError) export const getClearChatSessionApiChatSessionSessionIdDeleteUrl = (sessionId: string,) => { return `/api/chat/session/${sessionId}` } export const clearChatSessionApiChatSessionSessionIdDelete = async (sessionId: string, options?: RequestInit): Promise => { return customFetcher(getClearChatSessionApiChatSessionSessionIdDeleteUrl(sessionId), { ...options, method: 'DELETE' } );} export const getClearChatSessionApiChatSessionSessionIdDeleteMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{sessionId: string}, TContext> => { const mutationKey = ['clearChatSessionApiChatSessionSessionIdDelete']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {sessionId: string}> = (props) => { const {sessionId} = props ?? {}; return clearChatSessionApiChatSessionSessionIdDelete(sessionId,requestOptions) } return { mutationFn, ...mutationOptions }} export type ClearChatSessionApiChatSessionSessionIdDeleteMutationResult = NonNullable>> export type ClearChatSessionApiChatSessionSessionIdDeleteMutationError = HTTPValidationError /** * @summary Clear Chat Session */ export const useClearChatSessionApiChatSessionSessionIdDelete = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {sessionId: string}, TContext > => { return useMutation(getClearChatSessionApiChatSessionSessionIdDeleteMutationOptions(options), queryClient); } /** * 获取聊天历史记录(从数据库读取) * @summary Get Chat History */ export type getChatHistoryApiChatHistoryGetResponse200 = { data: unknown status: 200 } export type getChatHistoryApiChatHistoryGetResponse422 = { data: HTTPValidationError status: 422 } export type getChatHistoryApiChatHistoryGetResponseSuccess = (getChatHistoryApiChatHistoryGetResponse200) & { headers: Headers; }; export type getChatHistoryApiChatHistoryGetResponseError = (getChatHistoryApiChatHistoryGetResponse422) & { headers: Headers; }; export type getChatHistoryApiChatHistoryGetResponse = (getChatHistoryApiChatHistoryGetResponseSuccess | getChatHistoryApiChatHistoryGetResponseError) export const getGetChatHistoryApiChatHistoryGetUrl = (params?: GetChatHistoryApiChatHistoryGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/chat/history?${stringifiedParams}` : `/api/chat/history` } export const getChatHistoryApiChatHistoryGet = async (params?: GetChatHistoryApiChatHistoryGetParams, options?: RequestInit): Promise => { return customFetcher(getGetChatHistoryApiChatHistoryGetUrl(params), { ...options, method: 'GET' } );} export const getGetChatHistoryApiChatHistoryGetQueryKey = (params?: GetChatHistoryApiChatHistoryGetParams,) => { return [ `/api/chat/history`, ...(params ? [params] : []) ] as const; } export const getGetChatHistoryApiChatHistoryGetQueryOptions = >, TError = HTTPValidationError>(params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetChatHistoryApiChatHistoryGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getChatHistoryApiChatHistoryGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetChatHistoryApiChatHistoryGetQueryResult = NonNullable>> export type GetChatHistoryApiChatHistoryGetQueryError = HTTPValidationError export function useGetChatHistoryApiChatHistoryGet>, TError = HTTPValidationError>( params: undefined | GetChatHistoryApiChatHistoryGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetChatHistoryApiChatHistoryGet>, TError = HTTPValidationError>( params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetChatHistoryApiChatHistoryGet>, TError = HTTPValidationError>( params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Chat History */ export function useGetChatHistoryApiChatHistoryGet>, TError = HTTPValidationError>( params?: GetChatHistoryApiChatHistoryGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetChatHistoryApiChatHistoryGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取查询建议 * @summary Get Query Suggestions */ export type getQuerySuggestionsApiChatSuggestionsGetResponse200 = { data: unknown status: 200 } export type getQuerySuggestionsApiChatSuggestionsGetResponse422 = { data: HTTPValidationError status: 422 } export type getQuerySuggestionsApiChatSuggestionsGetResponseSuccess = (getQuerySuggestionsApiChatSuggestionsGetResponse200) & { headers: Headers; }; export type getQuerySuggestionsApiChatSuggestionsGetResponseError = (getQuerySuggestionsApiChatSuggestionsGetResponse422) & { headers: Headers; }; export type getQuerySuggestionsApiChatSuggestionsGetResponse = (getQuerySuggestionsApiChatSuggestionsGetResponseSuccess | getQuerySuggestionsApiChatSuggestionsGetResponseError) export const getGetQuerySuggestionsApiChatSuggestionsGetUrl = (params?: GetQuerySuggestionsApiChatSuggestionsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/chat/suggestions?${stringifiedParams}` : `/api/chat/suggestions` } export const getQuerySuggestionsApiChatSuggestionsGet = async (params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: RequestInit): Promise => { return customFetcher(getGetQuerySuggestionsApiChatSuggestionsGetUrl(params), { ...options, method: 'GET' } );} export const getGetQuerySuggestionsApiChatSuggestionsGetQueryKey = (params?: GetQuerySuggestionsApiChatSuggestionsGetParams,) => { return [ `/api/chat/suggestions`, ...(params ? [params] : []) ] as const; } export const getGetQuerySuggestionsApiChatSuggestionsGetQueryOptions = >, TError = HTTPValidationError>(params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetQuerySuggestionsApiChatSuggestionsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getQuerySuggestionsApiChatSuggestionsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetQuerySuggestionsApiChatSuggestionsGetQueryResult = NonNullable>> export type GetQuerySuggestionsApiChatSuggestionsGetQueryError = HTTPValidationError export function useGetQuerySuggestionsApiChatSuggestionsGet>, TError = HTTPValidationError>( params: undefined | GetQuerySuggestionsApiChatSuggestionsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetQuerySuggestionsApiChatSuggestionsGet>, TError = HTTPValidationError>( params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetQuerySuggestionsApiChatSuggestionsGet>, TError = HTTPValidationError>( params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Query Suggestions */ export function useGetQuerySuggestionsApiChatSuggestionsGet>, TError = HTTPValidationError>( params?: GetQuerySuggestionsApiChatSuggestionsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetQuerySuggestionsApiChatSuggestionsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取支持的查询类型 * @summary Get Supported Query Types */ export type getSupportedQueryTypesApiChatQueryTypesGetResponse200 = { data: unknown status: 200 } export type getSupportedQueryTypesApiChatQueryTypesGetResponseSuccess = (getSupportedQueryTypesApiChatQueryTypesGetResponse200) & { headers: Headers; }; ; export type getSupportedQueryTypesApiChatQueryTypesGetResponse = (getSupportedQueryTypesApiChatQueryTypesGetResponseSuccess) export const getGetSupportedQueryTypesApiChatQueryTypesGetUrl = () => { return `/api/chat/query-types` } export const getSupportedQueryTypesApiChatQueryTypesGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetSupportedQueryTypesApiChatQueryTypesGetUrl(), { ...options, method: 'GET' } );} export const getGetSupportedQueryTypesApiChatQueryTypesGetQueryKey = () => { return [ `/api/chat/query-types` ] as const; } export const getGetSupportedQueryTypesApiChatQueryTypesGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetSupportedQueryTypesApiChatQueryTypesGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getSupportedQueryTypesApiChatQueryTypesGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetSupportedQueryTypesApiChatQueryTypesGetQueryResult = NonNullable>> export type GetSupportedQueryTypesApiChatQueryTypesGetQueryError = unknown export function useGetSupportedQueryTypesApiChatQueryTypesGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetSupportedQueryTypesApiChatQueryTypesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetSupportedQueryTypesApiChatQueryTypesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Supported Query Types */ export function useGetSupportedQueryTypesApiChatQueryTypesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetSupportedQueryTypesApiChatQueryTypesGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取可用的 Agno Agent 工具列表 返回两种类型的工具: 1. FreeTodo 工具:待办管理相关(create_todo, list_todos 等) 2. 外部工具:联网搜索等(duckduckgo 等) * @summary Get Available Agno Tools */ export type getAvailableAgnoToolsApiChatAgnoToolsGetResponse200 = { data: unknown status: 200 } export type getAvailableAgnoToolsApiChatAgnoToolsGetResponseSuccess = (getAvailableAgnoToolsApiChatAgnoToolsGetResponse200) & { headers: Headers; }; ; export type getAvailableAgnoToolsApiChatAgnoToolsGetResponse = (getAvailableAgnoToolsApiChatAgnoToolsGetResponseSuccess) export const getGetAvailableAgnoToolsApiChatAgnoToolsGetUrl = () => { return `/api/chat/agno/tools` } export const getAvailableAgnoToolsApiChatAgnoToolsGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetAvailableAgnoToolsApiChatAgnoToolsGetUrl(), { ...options, method: 'GET' } );} export const getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryKey = () => { return [ `/api/chat/agno/tools` ] as const; } export const getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getAvailableAgnoToolsApiChatAgnoToolsGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetAvailableAgnoToolsApiChatAgnoToolsGetQueryResult = NonNullable>> export type GetAvailableAgnoToolsApiChatAgnoToolsGetQueryError = unknown export function useGetAvailableAgnoToolsApiChatAgnoToolsGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetAvailableAgnoToolsApiChatAgnoToolsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetAvailableAgnoToolsApiChatAgnoToolsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Available Agno Tools */ export function useGetAvailableAgnoToolsApiChatAgnoToolsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetAvailableAgnoToolsApiChatAgnoToolsGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * Plan功能:生成选择题(流式输出) * @summary Plan Questionnaire Stream */ export type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse200 = { data: unknown status: 200 } export type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse422 = { data: HTTPValidationError status: 422 } export type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseSuccess = (planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse200) & { headers: Headers; }; export type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseError = (planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse422) & { headers: Headers; }; export type planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponse = (planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseSuccess | planQuestionnaireStreamApiChatPlanQuestionnaireStreamPostResponseError) export const getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostUrl = () => { return `/api/chat/plan/questionnaire/stream` } export const planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost = async (planQuestionnaireRequest: PlanQuestionnaireRequest, options?: RequestInit): Promise => { return customFetcher(getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( planQuestionnaireRequest,) } );} export const getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: PlanQuestionnaireRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: PlanQuestionnaireRequest}, TContext> => { const mutationKey = ['planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: PlanQuestionnaireRequest}> = (props) => { const {data} = props ?? {}; return planQuestionnaireStreamApiChatPlanQuestionnaireStreamPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type PlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationResult = NonNullable>> export type PlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationBody = PlanQuestionnaireRequest export type PlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationError = HTTPValidationError /** * @summary Plan Questionnaire Stream */ export const usePlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPost = (options?: { mutation?:UseMutationOptions>, TError,{data: PlanQuestionnaireRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: PlanQuestionnaireRequest}, TContext > => { return useMutation(getPlanQuestionnaireStreamApiChatPlanQuestionnaireStreamPostMutationOptions(options), queryClient); } /** * Plan功能:生成任务总结和子任务(流式输出) * @summary Plan Summary Stream */ export type planSummaryStreamApiChatPlanSummaryStreamPostResponse200 = { data: unknown status: 200 } export type planSummaryStreamApiChatPlanSummaryStreamPostResponse422 = { data: HTTPValidationError status: 422 } export type planSummaryStreamApiChatPlanSummaryStreamPostResponseSuccess = (planSummaryStreamApiChatPlanSummaryStreamPostResponse200) & { headers: Headers; }; export type planSummaryStreamApiChatPlanSummaryStreamPostResponseError = (planSummaryStreamApiChatPlanSummaryStreamPostResponse422) & { headers: Headers; }; export type planSummaryStreamApiChatPlanSummaryStreamPostResponse = (planSummaryStreamApiChatPlanSummaryStreamPostResponseSuccess | planSummaryStreamApiChatPlanSummaryStreamPostResponseError) export const getPlanSummaryStreamApiChatPlanSummaryStreamPostUrl = () => { return `/api/chat/plan/summary/stream` } export const planSummaryStreamApiChatPlanSummaryStreamPost = async (planSummaryRequest: PlanSummaryRequest, options?: RequestInit): Promise => { return customFetcher(getPlanSummaryStreamApiChatPlanSummaryStreamPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( planSummaryRequest,) } );} export const getPlanSummaryStreamApiChatPlanSummaryStreamPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: PlanSummaryRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: PlanSummaryRequest}, TContext> => { const mutationKey = ['planSummaryStreamApiChatPlanSummaryStreamPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: PlanSummaryRequest}> = (props) => { const {data} = props ?? {}; return planSummaryStreamApiChatPlanSummaryStreamPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type PlanSummaryStreamApiChatPlanSummaryStreamPostMutationResult = NonNullable>> export type PlanSummaryStreamApiChatPlanSummaryStreamPostMutationBody = PlanSummaryRequest export type PlanSummaryStreamApiChatPlanSummaryStreamPostMutationError = HTTPValidationError /** * @summary Plan Summary Stream */ export const usePlanSummaryStreamApiChatPlanSummaryStreamPost = (options?: { mutation?:UseMutationOptions>, TError,{data: PlanSummaryRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: PlanSummaryRequest}, TContext > => { return useMutation(getPlanSummaryStreamApiChatPlanSummaryStreamPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/config/config.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { GetChatPromptsApiGetChatPromptsGetParams, HTTPValidationError, SaveAndInitLlmApiSaveAndInitLlmPostBody, SaveConfigApiSaveConfigPostBody, TestAsrConfigApiTestAsrConfigPostBody, TestLlmConfigApiTestLlmConfigPostBody, TestTavilyConfigApiTestTavilyConfigPostBody } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 测试LLM配置是否可用(仅验证认证) * @summary Test Llm Config */ export type testLlmConfigApiTestLlmConfigPostResponse200 = { data: unknown status: 200 } export type testLlmConfigApiTestLlmConfigPostResponse422 = { data: HTTPValidationError status: 422 } export type testLlmConfigApiTestLlmConfigPostResponseSuccess = (testLlmConfigApiTestLlmConfigPostResponse200) & { headers: Headers; }; export type testLlmConfigApiTestLlmConfigPostResponseError = (testLlmConfigApiTestLlmConfigPostResponse422) & { headers: Headers; }; export type testLlmConfigApiTestLlmConfigPostResponse = (testLlmConfigApiTestLlmConfigPostResponseSuccess | testLlmConfigApiTestLlmConfigPostResponseError) export const getTestLlmConfigApiTestLlmConfigPostUrl = () => { return `/api/test-llm-config` } export const testLlmConfigApiTestLlmConfigPost = async (testLlmConfigApiTestLlmConfigPostBody: TestLlmConfigApiTestLlmConfigPostBody, options?: RequestInit): Promise => { return customFetcher(getTestLlmConfigApiTestLlmConfigPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( testLlmConfigApiTestLlmConfigPostBody,) } );} export const getTestLlmConfigApiTestLlmConfigPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: TestLlmConfigApiTestLlmConfigPostBody}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: TestLlmConfigApiTestLlmConfigPostBody}, TContext> => { const mutationKey = ['testLlmConfigApiTestLlmConfigPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: TestLlmConfigApiTestLlmConfigPostBody}> = (props) => { const {data} = props ?? {}; return testLlmConfigApiTestLlmConfigPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type TestLlmConfigApiTestLlmConfigPostMutationResult = NonNullable>> export type TestLlmConfigApiTestLlmConfigPostMutationBody = TestLlmConfigApiTestLlmConfigPostBody export type TestLlmConfigApiTestLlmConfigPostMutationError = HTTPValidationError /** * @summary Test Llm Config */ export const useTestLlmConfigApiTestLlmConfigPost = (options?: { mutation?:UseMutationOptions>, TError,{data: TestLlmConfigApiTestLlmConfigPostBody}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: TestLlmConfigApiTestLlmConfigPostBody}, TContext > => { return useMutation(getTestLlmConfigApiTestLlmConfigPostMutationOptions(options), queryClient); } /** * 测试Tavily配置是否可用(仅验证认证) * @summary Test Tavily Config */ export type testTavilyConfigApiTestTavilyConfigPostResponse200 = { data: unknown status: 200 } export type testTavilyConfigApiTestTavilyConfigPostResponse422 = { data: HTTPValidationError status: 422 } export type testTavilyConfigApiTestTavilyConfigPostResponseSuccess = (testTavilyConfigApiTestTavilyConfigPostResponse200) & { headers: Headers; }; export type testTavilyConfigApiTestTavilyConfigPostResponseError = (testTavilyConfigApiTestTavilyConfigPostResponse422) & { headers: Headers; }; export type testTavilyConfigApiTestTavilyConfigPostResponse = (testTavilyConfigApiTestTavilyConfigPostResponseSuccess | testTavilyConfigApiTestTavilyConfigPostResponseError) export const getTestTavilyConfigApiTestTavilyConfigPostUrl = () => { return `/api/test-tavily-config` } export const testTavilyConfigApiTestTavilyConfigPost = async (testTavilyConfigApiTestTavilyConfigPostBody: TestTavilyConfigApiTestTavilyConfigPostBody, options?: RequestInit): Promise => { return customFetcher(getTestTavilyConfigApiTestTavilyConfigPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( testTavilyConfigApiTestTavilyConfigPostBody,) } );} export const getTestTavilyConfigApiTestTavilyConfigPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: TestTavilyConfigApiTestTavilyConfigPostBody}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: TestTavilyConfigApiTestTavilyConfigPostBody}, TContext> => { const mutationKey = ['testTavilyConfigApiTestTavilyConfigPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: TestTavilyConfigApiTestTavilyConfigPostBody}> = (props) => { const {data} = props ?? {}; return testTavilyConfigApiTestTavilyConfigPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type TestTavilyConfigApiTestTavilyConfigPostMutationResult = NonNullable>> export type TestTavilyConfigApiTestTavilyConfigPostMutationBody = TestTavilyConfigApiTestTavilyConfigPostBody export type TestTavilyConfigApiTestTavilyConfigPostMutationError = HTTPValidationError /** * @summary Test Tavily Config */ export const useTestTavilyConfigApiTestTavilyConfigPost = (options?: { mutation?:UseMutationOptions>, TError,{data: TestTavilyConfigApiTestTavilyConfigPostBody}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: TestTavilyConfigApiTestTavilyConfigPostBody}, TContext > => { return useMutation(getTestTavilyConfigApiTestTavilyConfigPostMutationOptions(options), queryClient); } /** * 测试ASR配置是否可用(验证WebSocket连接和认证) * @summary Test Asr Config */ export type testAsrConfigApiTestAsrConfigPostResponse200 = { data: unknown status: 200 } export type testAsrConfigApiTestAsrConfigPostResponse422 = { data: HTTPValidationError status: 422 } export type testAsrConfigApiTestAsrConfigPostResponseSuccess = (testAsrConfigApiTestAsrConfigPostResponse200) & { headers: Headers; }; export type testAsrConfigApiTestAsrConfigPostResponseError = (testAsrConfigApiTestAsrConfigPostResponse422) & { headers: Headers; }; export type testAsrConfigApiTestAsrConfigPostResponse = (testAsrConfigApiTestAsrConfigPostResponseSuccess | testAsrConfigApiTestAsrConfigPostResponseError) export const getTestAsrConfigApiTestAsrConfigPostUrl = () => { return `/api/test-asr-config` } export const testAsrConfigApiTestAsrConfigPost = async (testAsrConfigApiTestAsrConfigPostBody: TestAsrConfigApiTestAsrConfigPostBody, options?: RequestInit): Promise => { return customFetcher(getTestAsrConfigApiTestAsrConfigPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( testAsrConfigApiTestAsrConfigPostBody,) } );} export const getTestAsrConfigApiTestAsrConfigPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: TestAsrConfigApiTestAsrConfigPostBody}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: TestAsrConfigApiTestAsrConfigPostBody}, TContext> => { const mutationKey = ['testAsrConfigApiTestAsrConfigPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: TestAsrConfigApiTestAsrConfigPostBody}> = (props) => { const {data} = props ?? {}; return testAsrConfigApiTestAsrConfigPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type TestAsrConfigApiTestAsrConfigPostMutationResult = NonNullable>> export type TestAsrConfigApiTestAsrConfigPostMutationBody = TestAsrConfigApiTestAsrConfigPostBody export type TestAsrConfigApiTestAsrConfigPostMutationError = HTTPValidationError /** * @summary Test Asr Config */ export const useTestAsrConfigApiTestAsrConfigPost = (options?: { mutation?:UseMutationOptions>, TError,{data: TestAsrConfigApiTestAsrConfigPostBody}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: TestAsrConfigApiTestAsrConfigPostBody}, TContext > => { return useMutation(getTestAsrConfigApiTestAsrConfigPostMutationOptions(options), queryClient); } /** * 检查 LLM 是否已正确配置并通过连接测试 Returns: dict: 包含 configured 字段,表示 LLM 是否已配置且连接验证成功 * @summary Get Llm Status */ export type getLlmStatusApiLlmStatusGetResponse200 = { data: unknown status: 200 } export type getLlmStatusApiLlmStatusGetResponseSuccess = (getLlmStatusApiLlmStatusGetResponse200) & { headers: Headers; }; ; export type getLlmStatusApiLlmStatusGetResponse = (getLlmStatusApiLlmStatusGetResponseSuccess) export const getGetLlmStatusApiLlmStatusGetUrl = () => { return `/api/llm-status` } export const getLlmStatusApiLlmStatusGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetLlmStatusApiLlmStatusGetUrl(), { ...options, method: 'GET' } );} export const getGetLlmStatusApiLlmStatusGetQueryKey = () => { return [ `/api/llm-status` ] as const; } export const getGetLlmStatusApiLlmStatusGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetLlmStatusApiLlmStatusGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getLlmStatusApiLlmStatusGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetLlmStatusApiLlmStatusGetQueryResult = NonNullable>> export type GetLlmStatusApiLlmStatusGetQueryError = unknown export function useGetLlmStatusApiLlmStatusGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetLlmStatusApiLlmStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetLlmStatusApiLlmStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Llm Status */ export function useGetLlmStatusApiLlmStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetLlmStatusApiLlmStatusGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取当前配置(返回驼峰格式的配置键) * @summary Get Config Detailed */ export type getConfigDetailedApiGetConfigGetResponse200 = { data: unknown status: 200 } export type getConfigDetailedApiGetConfigGetResponseSuccess = (getConfigDetailedApiGetConfigGetResponse200) & { headers: Headers; }; ; export type getConfigDetailedApiGetConfigGetResponse = (getConfigDetailedApiGetConfigGetResponseSuccess) export const getGetConfigDetailedApiGetConfigGetUrl = () => { return `/api/get-config` } export const getConfigDetailedApiGetConfigGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetConfigDetailedApiGetConfigGetUrl(), { ...options, method: 'GET' } );} export const getGetConfigDetailedApiGetConfigGetQueryKey = () => { return [ `/api/get-config` ] as const; } export const getGetConfigDetailedApiGetConfigGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetConfigDetailedApiGetConfigGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getConfigDetailedApiGetConfigGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetConfigDetailedApiGetConfigGetQueryResult = NonNullable>> export type GetConfigDetailedApiGetConfigGetQueryError = unknown export function useGetConfigDetailedApiGetConfigGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetConfigDetailedApiGetConfigGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetConfigDetailedApiGetConfigGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Config Detailed */ export function useGetConfigDetailedApiGetConfigGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetConfigDetailedApiGetConfigGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 保存配置并重新初始化LLM服务 * @summary Save And Init Llm */ export type saveAndInitLlmApiSaveAndInitLlmPostResponse200 = { data: unknown status: 200 } export type saveAndInitLlmApiSaveAndInitLlmPostResponse422 = { data: HTTPValidationError status: 422 } export type saveAndInitLlmApiSaveAndInitLlmPostResponseSuccess = (saveAndInitLlmApiSaveAndInitLlmPostResponse200) & { headers: Headers; }; export type saveAndInitLlmApiSaveAndInitLlmPostResponseError = (saveAndInitLlmApiSaveAndInitLlmPostResponse422) & { headers: Headers; }; export type saveAndInitLlmApiSaveAndInitLlmPostResponse = (saveAndInitLlmApiSaveAndInitLlmPostResponseSuccess | saveAndInitLlmApiSaveAndInitLlmPostResponseError) export const getSaveAndInitLlmApiSaveAndInitLlmPostUrl = () => { return `/api/save-and-init-llm` } export const saveAndInitLlmApiSaveAndInitLlmPost = async (saveAndInitLlmApiSaveAndInitLlmPostBody: SaveAndInitLlmApiSaveAndInitLlmPostBody, options?: RequestInit): Promise => { return customFetcher(getSaveAndInitLlmApiSaveAndInitLlmPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( saveAndInitLlmApiSaveAndInitLlmPostBody,) } );} export const getSaveAndInitLlmApiSaveAndInitLlmPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: SaveAndInitLlmApiSaveAndInitLlmPostBody}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: SaveAndInitLlmApiSaveAndInitLlmPostBody}, TContext> => { const mutationKey = ['saveAndInitLlmApiSaveAndInitLlmPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: SaveAndInitLlmApiSaveAndInitLlmPostBody}> = (props) => { const {data} = props ?? {}; return saveAndInitLlmApiSaveAndInitLlmPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type SaveAndInitLlmApiSaveAndInitLlmPostMutationResult = NonNullable>> export type SaveAndInitLlmApiSaveAndInitLlmPostMutationBody = SaveAndInitLlmApiSaveAndInitLlmPostBody export type SaveAndInitLlmApiSaveAndInitLlmPostMutationError = HTTPValidationError /** * @summary Save And Init Llm */ export const useSaveAndInitLlmApiSaveAndInitLlmPost = (options?: { mutation?:UseMutationOptions>, TError,{data: SaveAndInitLlmApiSaveAndInitLlmPostBody}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: SaveAndInitLlmApiSaveAndInitLlmPostBody}, TContext > => { return useMutation(getSaveAndInitLlmApiSaveAndInitLlmPostMutationOptions(options), queryClient); } /** * 保存配置到config.yaml文件 * @summary Save Config */ export type saveConfigApiSaveConfigPostResponse200 = { data: unknown status: 200 } export type saveConfigApiSaveConfigPostResponse422 = { data: HTTPValidationError status: 422 } export type saveConfigApiSaveConfigPostResponseSuccess = (saveConfigApiSaveConfigPostResponse200) & { headers: Headers; }; export type saveConfigApiSaveConfigPostResponseError = (saveConfigApiSaveConfigPostResponse422) & { headers: Headers; }; export type saveConfigApiSaveConfigPostResponse = (saveConfigApiSaveConfigPostResponseSuccess | saveConfigApiSaveConfigPostResponseError) export const getSaveConfigApiSaveConfigPostUrl = () => { return `/api/save-config` } export const saveConfigApiSaveConfigPost = async (saveConfigApiSaveConfigPostBody: SaveConfigApiSaveConfigPostBody, options?: RequestInit): Promise => { return customFetcher(getSaveConfigApiSaveConfigPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( saveConfigApiSaveConfigPostBody,) } );} export const getSaveConfigApiSaveConfigPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: SaveConfigApiSaveConfigPostBody}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: SaveConfigApiSaveConfigPostBody}, TContext> => { const mutationKey = ['saveConfigApiSaveConfigPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: SaveConfigApiSaveConfigPostBody}> = (props) => { const {data} = props ?? {}; return saveConfigApiSaveConfigPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type SaveConfigApiSaveConfigPostMutationResult = NonNullable>> export type SaveConfigApiSaveConfigPostMutationBody = SaveConfigApiSaveConfigPostBody export type SaveConfigApiSaveConfigPostMutationError = HTTPValidationError /** * @summary Save Config */ export const useSaveConfigApiSaveConfigPost = (options?: { mutation?:UseMutationOptions>, TError,{data: SaveConfigApiSaveConfigPostBody}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: SaveConfigApiSaveConfigPostBody}, TContext > => { return useMutation(getSaveConfigApiSaveConfigPostMutationOptions(options), queryClient); } /** * 获取前端聊天功能所需的 prompt Args: locale: 语言代码,'zh' 或 'en',默认为 'zh' Returns: 包含 editSystemPrompt 和 planSystemPrompt 的字典 * @summary Get Chat Prompts */ export type getChatPromptsApiGetChatPromptsGetResponse200 = { data: unknown status: 200 } export type getChatPromptsApiGetChatPromptsGetResponse422 = { data: HTTPValidationError status: 422 } export type getChatPromptsApiGetChatPromptsGetResponseSuccess = (getChatPromptsApiGetChatPromptsGetResponse200) & { headers: Headers; }; export type getChatPromptsApiGetChatPromptsGetResponseError = (getChatPromptsApiGetChatPromptsGetResponse422) & { headers: Headers; }; export type getChatPromptsApiGetChatPromptsGetResponse = (getChatPromptsApiGetChatPromptsGetResponseSuccess | getChatPromptsApiGetChatPromptsGetResponseError) export const getGetChatPromptsApiGetChatPromptsGetUrl = (params?: GetChatPromptsApiGetChatPromptsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/get-chat-prompts?${stringifiedParams}` : `/api/get-chat-prompts` } export const getChatPromptsApiGetChatPromptsGet = async (params?: GetChatPromptsApiGetChatPromptsGetParams, options?: RequestInit): Promise => { return customFetcher(getGetChatPromptsApiGetChatPromptsGetUrl(params), { ...options, method: 'GET' } );} export const getGetChatPromptsApiGetChatPromptsGetQueryKey = (params?: GetChatPromptsApiGetChatPromptsGetParams,) => { return [ `/api/get-chat-prompts`, ...(params ? [params] : []) ] as const; } export const getGetChatPromptsApiGetChatPromptsGetQueryOptions = >, TError = HTTPValidationError>(params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetChatPromptsApiGetChatPromptsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getChatPromptsApiGetChatPromptsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetChatPromptsApiGetChatPromptsGetQueryResult = NonNullable>> export type GetChatPromptsApiGetChatPromptsGetQueryError = HTTPValidationError export function useGetChatPromptsApiGetChatPromptsGet>, TError = HTTPValidationError>( params: undefined | GetChatPromptsApiGetChatPromptsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetChatPromptsApiGetChatPromptsGet>, TError = HTTPValidationError>( params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetChatPromptsApiGetChatPromptsGet>, TError = HTTPValidationError>( params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Chat Prompts */ export function useGetChatPromptsApiGetChatPromptsGet>, TError = HTTPValidationError>( params?: GetChatPromptsApiGetChatPromptsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetChatPromptsApiGetChatPromptsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/cost-tracking/cost-tracking.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { GetCostStatsApiCostTrackingStatsGetParams, HTTPValidationError } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取费用统计数据 Args: days: 统计最近多少天的数据 * @summary Get Cost Stats */ export type getCostStatsApiCostTrackingStatsGetResponse200 = { data: unknown status: 200 } export type getCostStatsApiCostTrackingStatsGetResponse422 = { data: HTTPValidationError status: 422 } export type getCostStatsApiCostTrackingStatsGetResponseSuccess = (getCostStatsApiCostTrackingStatsGetResponse200) & { headers: Headers; }; export type getCostStatsApiCostTrackingStatsGetResponseError = (getCostStatsApiCostTrackingStatsGetResponse422) & { headers: Headers; }; export type getCostStatsApiCostTrackingStatsGetResponse = (getCostStatsApiCostTrackingStatsGetResponseSuccess | getCostStatsApiCostTrackingStatsGetResponseError) export const getGetCostStatsApiCostTrackingStatsGetUrl = (params?: GetCostStatsApiCostTrackingStatsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/cost-tracking/stats?${stringifiedParams}` : `/api/cost-tracking/stats` } export const getCostStatsApiCostTrackingStatsGet = async (params?: GetCostStatsApiCostTrackingStatsGetParams, options?: RequestInit): Promise => { return customFetcher(getGetCostStatsApiCostTrackingStatsGetUrl(params), { ...options, method: 'GET' } );} export const getGetCostStatsApiCostTrackingStatsGetQueryKey = (params?: GetCostStatsApiCostTrackingStatsGetParams,) => { return [ `/api/cost-tracking/stats`, ...(params ? [params] : []) ] as const; } export const getGetCostStatsApiCostTrackingStatsGetQueryOptions = >, TError = HTTPValidationError>(params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetCostStatsApiCostTrackingStatsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getCostStatsApiCostTrackingStatsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetCostStatsApiCostTrackingStatsGetQueryResult = NonNullable>> export type GetCostStatsApiCostTrackingStatsGetQueryError = HTTPValidationError export function useGetCostStatsApiCostTrackingStatsGet>, TError = HTTPValidationError>( params: undefined | GetCostStatsApiCostTrackingStatsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetCostStatsApiCostTrackingStatsGet>, TError = HTTPValidationError>( params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetCostStatsApiCostTrackingStatsGet>, TError = HTTPValidationError>( params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Cost Stats */ export function useGetCostStatsApiCostTrackingStatsGet>, TError = HTTPValidationError>( params?: GetCostStatsApiCostTrackingStatsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetCostStatsApiCostTrackingStatsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取费用统计配置 * @summary Get Cost Config */ export type getCostConfigApiCostTrackingConfigGetResponse200 = { data: unknown status: 200 } export type getCostConfigApiCostTrackingConfigGetResponseSuccess = (getCostConfigApiCostTrackingConfigGetResponse200) & { headers: Headers; }; ; export type getCostConfigApiCostTrackingConfigGetResponse = (getCostConfigApiCostTrackingConfigGetResponseSuccess) export const getGetCostConfigApiCostTrackingConfigGetUrl = () => { return `/api/cost-tracking/config` } export const getCostConfigApiCostTrackingConfigGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetCostConfigApiCostTrackingConfigGetUrl(), { ...options, method: 'GET' } );} export const getGetCostConfigApiCostTrackingConfigGetQueryKey = () => { return [ `/api/cost-tracking/config` ] as const; } export const getGetCostConfigApiCostTrackingConfigGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetCostConfigApiCostTrackingConfigGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getCostConfigApiCostTrackingConfigGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetCostConfigApiCostTrackingConfigGetQueryResult = NonNullable>> export type GetCostConfigApiCostTrackingConfigGetQueryError = unknown export function useGetCostConfigApiCostTrackingConfigGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetCostConfigApiCostTrackingConfigGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetCostConfigApiCostTrackingConfigGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Cost Config */ export function useGetCostConfigApiCostTrackingConfigGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetCostConfigApiCostTrackingConfigGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/default/default.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 健康检查 * @summary Health Check */ export type healthCheckHealthGetResponse200 = { data: unknown status: 200 } export type healthCheckHealthGetResponseSuccess = (healthCheckHealthGetResponse200) & { headers: Headers; }; ; export type healthCheckHealthGetResponse = (healthCheckHealthGetResponseSuccess) export const getHealthCheckHealthGetUrl = () => { return `/health` } export const healthCheckHealthGet = async ( options?: RequestInit): Promise => { return customFetcher(getHealthCheckHealthGetUrl(), { ...options, method: 'GET' } );} export const getHealthCheckHealthGetQueryKey = () => { return [ `/health` ] as const; } export const getHealthCheckHealthGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getHealthCheckHealthGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => healthCheckHealthGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type HealthCheckHealthGetQueryResult = NonNullable>> export type HealthCheckHealthGetQueryError = unknown export function useHealthCheckHealthGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useHealthCheckHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useHealthCheckHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Health Check */ export function useHealthCheckHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getHealthCheckHealthGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * LLM服务健康检查 * @summary Llm Health Check */ export type llmHealthCheckHealthLlmGetResponse200 = { data: unknown status: 200 } export type llmHealthCheckHealthLlmGetResponseSuccess = (llmHealthCheckHealthLlmGetResponse200) & { headers: Headers; }; ; export type llmHealthCheckHealthLlmGetResponse = (llmHealthCheckHealthLlmGetResponseSuccess) export const getLlmHealthCheckHealthLlmGetUrl = () => { return `/health/llm` } export const llmHealthCheckHealthLlmGet = async ( options?: RequestInit): Promise => { return customFetcher(getLlmHealthCheckHealthLlmGetUrl(), { ...options, method: 'GET' } );} export const getLlmHealthCheckHealthLlmGetQueryKey = () => { return [ `/health/llm` ] as const; } export const getLlmHealthCheckHealthLlmGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getLlmHealthCheckHealthLlmGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => llmHealthCheckHealthLlmGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type LlmHealthCheckHealthLlmGetQueryResult = NonNullable>> export type LlmHealthCheckHealthLlmGetQueryError = unknown export function useLlmHealthCheckHealthLlmGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useLlmHealthCheckHealthLlmGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useLlmHealthCheckHealthLlmGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Llm Health Check */ export function useLlmHealthCheckHealthLlmGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getLlmHealthCheckHealthLlmGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/event/event.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { CountEventsApiEventsCountGetParams, EventDetailResponse, EventListResponse, HTTPValidationError, ListEventsApiEventsGetParams } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取事件列表(事件=前台应用使用阶段),用于事件级别展示与检索,同时返回总数 * @summary List Events */ export type listEventsApiEventsGetResponse200 = { data: EventListResponse status: 200 } export type listEventsApiEventsGetResponse422 = { data: HTTPValidationError status: 422 } export type listEventsApiEventsGetResponseSuccess = (listEventsApiEventsGetResponse200) & { headers: Headers; }; export type listEventsApiEventsGetResponseError = (listEventsApiEventsGetResponse422) & { headers: Headers; }; export type listEventsApiEventsGetResponse = (listEventsApiEventsGetResponseSuccess | listEventsApiEventsGetResponseError) export const getListEventsApiEventsGetUrl = (params?: ListEventsApiEventsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/events?${stringifiedParams}` : `/api/events` } export const listEventsApiEventsGet = async (params?: ListEventsApiEventsGetParams, options?: RequestInit): Promise => { return customFetcher(getListEventsApiEventsGetUrl(params), { ...options, method: 'GET' } );} export const getListEventsApiEventsGetQueryKey = (params?: ListEventsApiEventsGetParams,) => { return [ `/api/events`, ...(params ? [params] : []) ] as const; } export const getListEventsApiEventsGetQueryOptions = >, TError = HTTPValidationError>(params?: ListEventsApiEventsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListEventsApiEventsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => listEventsApiEventsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type ListEventsApiEventsGetQueryResult = NonNullable>> export type ListEventsApiEventsGetQueryError = HTTPValidationError export function useListEventsApiEventsGet>, TError = HTTPValidationError>( params: undefined | ListEventsApiEventsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useListEventsApiEventsGet>, TError = HTTPValidationError>( params?: ListEventsApiEventsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useListEventsApiEventsGet>, TError = HTTPValidationError>( params?: ListEventsApiEventsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary List Events */ export function useListEventsApiEventsGet>, TError = HTTPValidationError>( params?: ListEventsApiEventsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getListEventsApiEventsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取事件总数 * @summary Count Events */ export type countEventsApiEventsCountGetResponse200 = { data: unknown status: 200 } export type countEventsApiEventsCountGetResponse422 = { data: HTTPValidationError status: 422 } export type countEventsApiEventsCountGetResponseSuccess = (countEventsApiEventsCountGetResponse200) & { headers: Headers; }; export type countEventsApiEventsCountGetResponseError = (countEventsApiEventsCountGetResponse422) & { headers: Headers; }; export type countEventsApiEventsCountGetResponse = (countEventsApiEventsCountGetResponseSuccess | countEventsApiEventsCountGetResponseError) export const getCountEventsApiEventsCountGetUrl = (params?: CountEventsApiEventsCountGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/events/count?${stringifiedParams}` : `/api/events/count` } export const countEventsApiEventsCountGet = async (params?: CountEventsApiEventsCountGetParams, options?: RequestInit): Promise => { return customFetcher(getCountEventsApiEventsCountGetUrl(params), { ...options, method: 'GET' } );} export const getCountEventsApiEventsCountGetQueryKey = (params?: CountEventsApiEventsCountGetParams,) => { return [ `/api/events/count`, ...(params ? [params] : []) ] as const; } export const getCountEventsApiEventsCountGetQueryOptions = >, TError = HTTPValidationError>(params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getCountEventsApiEventsCountGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => countEventsApiEventsCountGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type CountEventsApiEventsCountGetQueryResult = NonNullable>> export type CountEventsApiEventsCountGetQueryError = HTTPValidationError export function useCountEventsApiEventsCountGet>, TError = HTTPValidationError>( params: undefined | CountEventsApiEventsCountGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useCountEventsApiEventsCountGet>, TError = HTTPValidationError>( params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useCountEventsApiEventsCountGet>, TError = HTTPValidationError>( params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Count Events */ export function useCountEventsApiEventsCountGet>, TError = HTTPValidationError>( params?: CountEventsApiEventsCountGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getCountEventsApiEventsCountGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取事件详情(包含该事件下的截图列表) * @summary Get Event Detail */ export type getEventDetailApiEventsEventIdGetResponse200 = { data: EventDetailResponse status: 200 } export type getEventDetailApiEventsEventIdGetResponse422 = { data: HTTPValidationError status: 422 } export type getEventDetailApiEventsEventIdGetResponseSuccess = (getEventDetailApiEventsEventIdGetResponse200) & { headers: Headers; }; export type getEventDetailApiEventsEventIdGetResponseError = (getEventDetailApiEventsEventIdGetResponse422) & { headers: Headers; }; export type getEventDetailApiEventsEventIdGetResponse = (getEventDetailApiEventsEventIdGetResponseSuccess | getEventDetailApiEventsEventIdGetResponseError) export const getGetEventDetailApiEventsEventIdGetUrl = (eventId: number,) => { return `/api/events/${eventId}` } export const getEventDetailApiEventsEventIdGet = async (eventId: number, options?: RequestInit): Promise => { return customFetcher(getGetEventDetailApiEventsEventIdGetUrl(eventId), { ...options, method: 'GET' } );} export const getGetEventDetailApiEventsEventIdGetQueryKey = (eventId: number,) => { return [ `/api/events/${eventId}` ] as const; } export const getGetEventDetailApiEventsEventIdGetQueryOptions = >, TError = HTTPValidationError>(eventId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetEventDetailApiEventsEventIdGetQueryKey(eventId); const queryFn: QueryFunction>> = ({ signal }) => getEventDetailApiEventsEventIdGet(eventId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(eventId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetEventDetailApiEventsEventIdGetQueryResult = NonNullable>> export type GetEventDetailApiEventsEventIdGetQueryError = HTTPValidationError export function useGetEventDetailApiEventsEventIdGet>, TError = HTTPValidationError>( eventId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetEventDetailApiEventsEventIdGet>, TError = HTTPValidationError>( eventId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetEventDetailApiEventsEventIdGet>, TError = HTTPValidationError>( eventId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Event Detail */ export function useGetEventDetailApiEventsEventIdGet>, TError = HTTPValidationError>( eventId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetEventDetailApiEventsEventIdGetQueryOptions(eventId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取事件的OCR文本上下文 * @summary Get Event Context */ export type getEventContextApiEventsEventIdContextGetResponse200 = { data: unknown status: 200 } export type getEventContextApiEventsEventIdContextGetResponse422 = { data: HTTPValidationError status: 422 } export type getEventContextApiEventsEventIdContextGetResponseSuccess = (getEventContextApiEventsEventIdContextGetResponse200) & { headers: Headers; }; export type getEventContextApiEventsEventIdContextGetResponseError = (getEventContextApiEventsEventIdContextGetResponse422) & { headers: Headers; }; export type getEventContextApiEventsEventIdContextGetResponse = (getEventContextApiEventsEventIdContextGetResponseSuccess | getEventContextApiEventsEventIdContextGetResponseError) export const getGetEventContextApiEventsEventIdContextGetUrl = (eventId: number,) => { return `/api/events/${eventId}/context` } export const getEventContextApiEventsEventIdContextGet = async (eventId: number, options?: RequestInit): Promise => { return customFetcher(getGetEventContextApiEventsEventIdContextGetUrl(eventId), { ...options, method: 'GET' } );} export const getGetEventContextApiEventsEventIdContextGetQueryKey = (eventId: number,) => { return [ `/api/events/${eventId}/context` ] as const; } export const getGetEventContextApiEventsEventIdContextGetQueryOptions = >, TError = HTTPValidationError>(eventId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetEventContextApiEventsEventIdContextGetQueryKey(eventId); const queryFn: QueryFunction>> = ({ signal }) => getEventContextApiEventsEventIdContextGet(eventId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(eventId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetEventContextApiEventsEventIdContextGetQueryResult = NonNullable>> export type GetEventContextApiEventsEventIdContextGetQueryError = HTTPValidationError export function useGetEventContextApiEventsEventIdContextGet>, TError = HTTPValidationError>( eventId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetEventContextApiEventsEventIdContextGet>, TError = HTTPValidationError>( eventId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetEventContextApiEventsEventIdContextGet>, TError = HTTPValidationError>( eventId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Event Context */ export function useGetEventContextApiEventsEventIdContextGet>, TError = HTTPValidationError>( eventId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetEventContextApiEventsEventIdContextGetQueryOptions(eventId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 手动触发单个事件的摘要生成 * @summary Generate Event Summary */ export type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse200 = { data: unknown status: 200 } export type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse422 = { data: HTTPValidationError status: 422 } export type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseSuccess = (generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse200) & { headers: Headers; }; export type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseError = (generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse422) & { headers: Headers; }; export type generateEventSummaryApiEventsEventIdGenerateSummaryPostResponse = (generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseSuccess | generateEventSummaryApiEventsEventIdGenerateSummaryPostResponseError) export const getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostUrl = (eventId: number,) => { return `/api/events/${eventId}/generate-summary` } export const generateEventSummaryApiEventsEventIdGenerateSummaryPost = async (eventId: number, options?: RequestInit): Promise => { return customFetcher(getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostUrl(eventId), { ...options, method: 'POST' } );} export const getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{eventId: number}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{eventId: number}, TContext> => { const mutationKey = ['generateEventSummaryApiEventsEventIdGenerateSummaryPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {eventId: number}> = (props) => { const {eventId} = props ?? {}; return generateEventSummaryApiEventsEventIdGenerateSummaryPost(eventId,requestOptions) } return { mutationFn, ...mutationOptions }} export type GenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationResult = NonNullable>> export type GenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationError = HTTPValidationError /** * @summary Generate Event Summary */ export const useGenerateEventSummaryApiEventsEventIdGenerateSummaryPost = (options?: { mutation?:UseMutationOptions>, TError,{eventId: number}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {eventId: number}, TContext > => { return useMutation(getGenerateEventSummaryApiEventsEventIdGenerateSummaryPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/floating-capture/floating-capture.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { FloatingCaptureRequest, FloatingCaptureResponse, HTTPValidationError } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 从悬浮窗截图中提取待办事项 Args: request: 包含 base64 编码截图的请求 Returns: 提取和创建的待办事项列表 * @summary Extract Todos From Capture */ export type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse200 = { data: FloatingCaptureResponse status: 200 } export type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse422 = { data: HTTPValidationError status: 422 } export type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseSuccess = (extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse200) & { headers: Headers; }; export type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseError = (extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse422) & { headers: Headers; }; export type extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponse = (extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseSuccess | extractTodosFromCaptureApiFloatingCaptureExtractTodosPostResponseError) export const getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostUrl = () => { return `/api/floating-capture/extract-todos` } export const extractTodosFromCaptureApiFloatingCaptureExtractTodosPost = async (floatingCaptureRequest: FloatingCaptureRequest, options?: RequestInit): Promise => { return customFetcher(getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( floatingCaptureRequest,) } );} export const getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: FloatingCaptureRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: FloatingCaptureRequest}, TContext> => { const mutationKey = ['extractTodosFromCaptureApiFloatingCaptureExtractTodosPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: FloatingCaptureRequest}> = (props) => { const {data} = props ?? {}; return extractTodosFromCaptureApiFloatingCaptureExtractTodosPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationResult = NonNullable>> export type ExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationBody = FloatingCaptureRequest export type ExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationError = HTTPValidationError /** * @summary Extract Todos From Capture */ export const useExtractTodosFromCaptureApiFloatingCaptureExtractTodosPost = (options?: { mutation?:UseMutationOptions>, TError,{data: FloatingCaptureRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: FloatingCaptureRequest}, TContext > => { return useMutation(getExtractTodosFromCaptureApiFloatingCaptureExtractTodosPostMutationOptions(options), queryClient); } /** * 健康检查 * @summary Health Check */ export type healthCheckApiFloatingCaptureHealthGetResponse200 = { data: unknown status: 200 } export type healthCheckApiFloatingCaptureHealthGetResponseSuccess = (healthCheckApiFloatingCaptureHealthGetResponse200) & { headers: Headers; }; ; export type healthCheckApiFloatingCaptureHealthGetResponse = (healthCheckApiFloatingCaptureHealthGetResponseSuccess) export const getHealthCheckApiFloatingCaptureHealthGetUrl = () => { return `/api/floating-capture/health` } export const healthCheckApiFloatingCaptureHealthGet = async ( options?: RequestInit): Promise => { return customFetcher(getHealthCheckApiFloatingCaptureHealthGetUrl(), { ...options, method: 'GET' } );} export const getHealthCheckApiFloatingCaptureHealthGetQueryKey = () => { return [ `/api/floating-capture/health` ] as const; } export const getHealthCheckApiFloatingCaptureHealthGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getHealthCheckApiFloatingCaptureHealthGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => healthCheckApiFloatingCaptureHealthGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type HealthCheckApiFloatingCaptureHealthGetQueryResult = NonNullable>> export type HealthCheckApiFloatingCaptureHealthGetQueryError = unknown export function useHealthCheckApiFloatingCaptureHealthGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useHealthCheckApiFloatingCaptureHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useHealthCheckApiFloatingCaptureHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Health Check */ export function useHealthCheckApiFloatingCaptureHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getHealthCheckApiFloatingCaptureHealthGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/journals/journals.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { HTTPValidationError, JournalAutoLinkRequest, JournalAutoLinkResponse, JournalCreate, JournalGenerateRequest, JournalGenerateResponse, JournalListResponse, JournalResponse, JournalUpdate, ListJournalsApiJournalsGetParams } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 创建日记 * @summary Create Journal */ export type createJournalApiJournalsPostResponse201 = { data: JournalResponse status: 201 } export type createJournalApiJournalsPostResponse422 = { data: HTTPValidationError status: 422 } export type createJournalApiJournalsPostResponseSuccess = (createJournalApiJournalsPostResponse201) & { headers: Headers; }; export type createJournalApiJournalsPostResponseError = (createJournalApiJournalsPostResponse422) & { headers: Headers; }; export type createJournalApiJournalsPostResponse = (createJournalApiJournalsPostResponseSuccess | createJournalApiJournalsPostResponseError) export const getCreateJournalApiJournalsPostUrl = () => { return `/api/journals` } export const createJournalApiJournalsPost = async (journalCreate: JournalCreate, options?: RequestInit): Promise => { return customFetcher(getCreateJournalApiJournalsPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( journalCreate,) } );} export const getCreateJournalApiJournalsPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalCreate}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: JournalCreate}, TContext> => { const mutationKey = ['createJournalApiJournalsPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: JournalCreate}> = (props) => { const {data} = props ?? {}; return createJournalApiJournalsPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type CreateJournalApiJournalsPostMutationResult = NonNullable>> export type CreateJournalApiJournalsPostMutationBody = JournalCreate export type CreateJournalApiJournalsPostMutationError = HTTPValidationError /** * @summary Create Journal */ export const useCreateJournalApiJournalsPost = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalCreate}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: JournalCreate}, TContext > => { return useMutation(getCreateJournalApiJournalsPostMutationOptions(options), queryClient); } /** * 获取日记列表 * @summary List Journals */ export type listJournalsApiJournalsGetResponse200 = { data: JournalListResponse status: 200 } export type listJournalsApiJournalsGetResponse422 = { data: HTTPValidationError status: 422 } export type listJournalsApiJournalsGetResponseSuccess = (listJournalsApiJournalsGetResponse200) & { headers: Headers; }; export type listJournalsApiJournalsGetResponseError = (listJournalsApiJournalsGetResponse422) & { headers: Headers; }; export type listJournalsApiJournalsGetResponse = (listJournalsApiJournalsGetResponseSuccess | listJournalsApiJournalsGetResponseError) export const getListJournalsApiJournalsGetUrl = (params?: ListJournalsApiJournalsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/journals?${stringifiedParams}` : `/api/journals` } export const listJournalsApiJournalsGet = async (params?: ListJournalsApiJournalsGetParams, options?: RequestInit): Promise => { return customFetcher(getListJournalsApiJournalsGetUrl(params), { ...options, method: 'GET' } );} export const getListJournalsApiJournalsGetQueryKey = (params?: ListJournalsApiJournalsGetParams,) => { return [ `/api/journals`, ...(params ? [params] : []) ] as const; } export const getListJournalsApiJournalsGetQueryOptions = >, TError = HTTPValidationError>(params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListJournalsApiJournalsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => listJournalsApiJournalsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type ListJournalsApiJournalsGetQueryResult = NonNullable>> export type ListJournalsApiJournalsGetQueryError = HTTPValidationError export function useListJournalsApiJournalsGet>, TError = HTTPValidationError>( params: undefined | ListJournalsApiJournalsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useListJournalsApiJournalsGet>, TError = HTTPValidationError>( params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useListJournalsApiJournalsGet>, TError = HTTPValidationError>( params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary List Journals */ export function useListJournalsApiJournalsGet>, TError = HTTPValidationError>( params?: ListJournalsApiJournalsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getListJournalsApiJournalsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取日记详情 * @summary Get Journal */ export type getJournalApiJournalsJournalIdGetResponse200 = { data: JournalResponse status: 200 } export type getJournalApiJournalsJournalIdGetResponse422 = { data: HTTPValidationError status: 422 } export type getJournalApiJournalsJournalIdGetResponseSuccess = (getJournalApiJournalsJournalIdGetResponse200) & { headers: Headers; }; export type getJournalApiJournalsJournalIdGetResponseError = (getJournalApiJournalsJournalIdGetResponse422) & { headers: Headers; }; export type getJournalApiJournalsJournalIdGetResponse = (getJournalApiJournalsJournalIdGetResponseSuccess | getJournalApiJournalsJournalIdGetResponseError) export const getGetJournalApiJournalsJournalIdGetUrl = (journalId: number,) => { return `/api/journals/${journalId}` } export const getJournalApiJournalsJournalIdGet = async (journalId: number, options?: RequestInit): Promise => { return customFetcher(getGetJournalApiJournalsJournalIdGetUrl(journalId), { ...options, method: 'GET' } );} export const getGetJournalApiJournalsJournalIdGetQueryKey = (journalId: number,) => { return [ `/api/journals/${journalId}` ] as const; } export const getGetJournalApiJournalsJournalIdGetQueryOptions = >, TError = HTTPValidationError>(journalId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetJournalApiJournalsJournalIdGetQueryKey(journalId); const queryFn: QueryFunction>> = ({ signal }) => getJournalApiJournalsJournalIdGet(journalId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(journalId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetJournalApiJournalsJournalIdGetQueryResult = NonNullable>> export type GetJournalApiJournalsJournalIdGetQueryError = HTTPValidationError export function useGetJournalApiJournalsJournalIdGet>, TError = HTTPValidationError>( journalId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetJournalApiJournalsJournalIdGet>, TError = HTTPValidationError>( journalId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetJournalApiJournalsJournalIdGet>, TError = HTTPValidationError>( journalId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Journal */ export function useGetJournalApiJournalsJournalIdGet>, TError = HTTPValidationError>( journalId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetJournalApiJournalsJournalIdGetQueryOptions(journalId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 更新日记 * @summary Update Journal */ export type updateJournalApiJournalsJournalIdPutResponse200 = { data: JournalResponse status: 200 } export type updateJournalApiJournalsJournalIdPutResponse422 = { data: HTTPValidationError status: 422 } export type updateJournalApiJournalsJournalIdPutResponseSuccess = (updateJournalApiJournalsJournalIdPutResponse200) & { headers: Headers; }; export type updateJournalApiJournalsJournalIdPutResponseError = (updateJournalApiJournalsJournalIdPutResponse422) & { headers: Headers; }; export type updateJournalApiJournalsJournalIdPutResponse = (updateJournalApiJournalsJournalIdPutResponseSuccess | updateJournalApiJournalsJournalIdPutResponseError) export const getUpdateJournalApiJournalsJournalIdPutUrl = (journalId: number,) => { return `/api/journals/${journalId}` } export const updateJournalApiJournalsJournalIdPut = async (journalId: number, journalUpdateNull: JournalUpdate | null, options?: RequestInit): Promise => { return customFetcher(getUpdateJournalApiJournalsJournalIdPutUrl(journalId), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( journalUpdateNull,) } );} export const getUpdateJournalApiJournalsJournalIdPutMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{journalId: number;data: JournalUpdate | null}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{journalId: number;data: JournalUpdate | null}, TContext> => { const mutationKey = ['updateJournalApiJournalsJournalIdPut']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {journalId: number;data: JournalUpdate | null}> = (props) => { const {journalId,data} = props ?? {}; return updateJournalApiJournalsJournalIdPut(journalId,data,requestOptions) } return { mutationFn, ...mutationOptions }} export type UpdateJournalApiJournalsJournalIdPutMutationResult = NonNullable>> export type UpdateJournalApiJournalsJournalIdPutMutationBody = JournalUpdate | null export type UpdateJournalApiJournalsJournalIdPutMutationError = HTTPValidationError /** * @summary Update Journal */ export const useUpdateJournalApiJournalsJournalIdPut = (options?: { mutation?:UseMutationOptions>, TError,{journalId: number;data: JournalUpdate | null}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {journalId: number;data: JournalUpdate | null}, TContext > => { return useMutation(getUpdateJournalApiJournalsJournalIdPutMutationOptions(options), queryClient); } /** * 删除日记 * @summary Delete Journal */ export type deleteJournalApiJournalsJournalIdDeleteResponse204 = { data: void status: 204 } export type deleteJournalApiJournalsJournalIdDeleteResponse422 = { data: HTTPValidationError status: 422 } export type deleteJournalApiJournalsJournalIdDeleteResponseSuccess = (deleteJournalApiJournalsJournalIdDeleteResponse204) & { headers: Headers; }; export type deleteJournalApiJournalsJournalIdDeleteResponseError = (deleteJournalApiJournalsJournalIdDeleteResponse422) & { headers: Headers; }; export type deleteJournalApiJournalsJournalIdDeleteResponse = (deleteJournalApiJournalsJournalIdDeleteResponseSuccess | deleteJournalApiJournalsJournalIdDeleteResponseError) export const getDeleteJournalApiJournalsJournalIdDeleteUrl = (journalId: number,) => { return `/api/journals/${journalId}` } export const deleteJournalApiJournalsJournalIdDelete = async (journalId: number, options?: RequestInit): Promise => { return customFetcher(getDeleteJournalApiJournalsJournalIdDeleteUrl(journalId), { ...options, method: 'DELETE' } );} export const getDeleteJournalApiJournalsJournalIdDeleteMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{journalId: number}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{journalId: number}, TContext> => { const mutationKey = ['deleteJournalApiJournalsJournalIdDelete']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {journalId: number}> = (props) => { const {journalId} = props ?? {}; return deleteJournalApiJournalsJournalIdDelete(journalId,requestOptions) } return { mutationFn, ...mutationOptions }} export type DeleteJournalApiJournalsJournalIdDeleteMutationResult = NonNullable>> export type DeleteJournalApiJournalsJournalIdDeleteMutationError = HTTPValidationError /** * @summary Delete Journal */ export const useDeleteJournalApiJournalsJournalIdDelete = (options?: { mutation?:UseMutationOptions>, TError,{journalId: number}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {journalId: number}, TContext > => { return useMutation(getDeleteJournalApiJournalsJournalIdDeleteMutationOptions(options), queryClient); } /** * 自动关联 Todo/活动 * @summary Auto Link Journal */ export type autoLinkJournalApiJournalsAutoLinkPostResponse200 = { data: JournalAutoLinkResponse status: 200 } export type autoLinkJournalApiJournalsAutoLinkPostResponse422 = { data: HTTPValidationError status: 422 } export type autoLinkJournalApiJournalsAutoLinkPostResponseSuccess = (autoLinkJournalApiJournalsAutoLinkPostResponse200) & { headers: Headers; }; export type autoLinkJournalApiJournalsAutoLinkPostResponseError = (autoLinkJournalApiJournalsAutoLinkPostResponse422) & { headers: Headers; }; export type autoLinkJournalApiJournalsAutoLinkPostResponse = (autoLinkJournalApiJournalsAutoLinkPostResponseSuccess | autoLinkJournalApiJournalsAutoLinkPostResponseError) export const getAutoLinkJournalApiJournalsAutoLinkPostUrl = () => { return `/api/journals/auto-link` } export const autoLinkJournalApiJournalsAutoLinkPost = async (journalAutoLinkRequest: JournalAutoLinkRequest, options?: RequestInit): Promise => { return customFetcher(getAutoLinkJournalApiJournalsAutoLinkPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( journalAutoLinkRequest,) } );} export const getAutoLinkJournalApiJournalsAutoLinkPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalAutoLinkRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: JournalAutoLinkRequest}, TContext> => { const mutationKey = ['autoLinkJournalApiJournalsAutoLinkPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: JournalAutoLinkRequest}> = (props) => { const {data} = props ?? {}; return autoLinkJournalApiJournalsAutoLinkPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type AutoLinkJournalApiJournalsAutoLinkPostMutationResult = NonNullable>> export type AutoLinkJournalApiJournalsAutoLinkPostMutationBody = JournalAutoLinkRequest export type AutoLinkJournalApiJournalsAutoLinkPostMutationError = HTTPValidationError /** * @summary Auto Link Journal */ export const useAutoLinkJournalApiJournalsAutoLinkPost = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalAutoLinkRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: JournalAutoLinkRequest}, TContext > => { return useMutation(getAutoLinkJournalApiJournalsAutoLinkPostMutationOptions(options), queryClient); } /** * 生成客观记录 * @summary Generate Objective Journal */ export type generateObjectiveJournalApiJournalsGenerateObjectivePostResponse200 = { data: JournalGenerateResponse status: 200 } export type generateObjectiveJournalApiJournalsGenerateObjectivePostResponse422 = { data: HTTPValidationError status: 422 } export type generateObjectiveJournalApiJournalsGenerateObjectivePostResponseSuccess = (generateObjectiveJournalApiJournalsGenerateObjectivePostResponse200) & { headers: Headers; }; export type generateObjectiveJournalApiJournalsGenerateObjectivePostResponseError = (generateObjectiveJournalApiJournalsGenerateObjectivePostResponse422) & { headers: Headers; }; export type generateObjectiveJournalApiJournalsGenerateObjectivePostResponse = (generateObjectiveJournalApiJournalsGenerateObjectivePostResponseSuccess | generateObjectiveJournalApiJournalsGenerateObjectivePostResponseError) export const getGenerateObjectiveJournalApiJournalsGenerateObjectivePostUrl = () => { return `/api/journals/generate-objective` } export const generateObjectiveJournalApiJournalsGenerateObjectivePost = async (journalGenerateRequest: JournalGenerateRequest, options?: RequestInit): Promise => { return customFetcher(getGenerateObjectiveJournalApiJournalsGenerateObjectivePostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( journalGenerateRequest,) } );} export const getGenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: JournalGenerateRequest}, TContext> => { const mutationKey = ['generateObjectiveJournalApiJournalsGenerateObjectivePost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: JournalGenerateRequest}> = (props) => { const {data} = props ?? {}; return generateObjectiveJournalApiJournalsGenerateObjectivePost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type GenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationResult = NonNullable>> export type GenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationBody = JournalGenerateRequest export type GenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationError = HTTPValidationError /** * @summary Generate Objective Journal */ export const useGenerateObjectiveJournalApiJournalsGenerateObjectivePost = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: JournalGenerateRequest}, TContext > => { return useMutation(getGenerateObjectiveJournalApiJournalsGenerateObjectivePostMutationOptions(options), queryClient); } /** * 生成 AI 视角记录 * @summary Generate Ai Journal */ export type generateAiJournalApiJournalsGenerateAiPostResponse200 = { data: JournalGenerateResponse status: 200 } export type generateAiJournalApiJournalsGenerateAiPostResponse422 = { data: HTTPValidationError status: 422 } export type generateAiJournalApiJournalsGenerateAiPostResponseSuccess = (generateAiJournalApiJournalsGenerateAiPostResponse200) & { headers: Headers; }; export type generateAiJournalApiJournalsGenerateAiPostResponseError = (generateAiJournalApiJournalsGenerateAiPostResponse422) & { headers: Headers; }; export type generateAiJournalApiJournalsGenerateAiPostResponse = (generateAiJournalApiJournalsGenerateAiPostResponseSuccess | generateAiJournalApiJournalsGenerateAiPostResponseError) export const getGenerateAiJournalApiJournalsGenerateAiPostUrl = () => { return `/api/journals/generate-ai` } export const generateAiJournalApiJournalsGenerateAiPost = async (journalGenerateRequest: JournalGenerateRequest, options?: RequestInit): Promise => { return customFetcher(getGenerateAiJournalApiJournalsGenerateAiPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( journalGenerateRequest,) } );} export const getGenerateAiJournalApiJournalsGenerateAiPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: JournalGenerateRequest}, TContext> => { const mutationKey = ['generateAiJournalApiJournalsGenerateAiPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: JournalGenerateRequest}> = (props) => { const {data} = props ?? {}; return generateAiJournalApiJournalsGenerateAiPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type GenerateAiJournalApiJournalsGenerateAiPostMutationResult = NonNullable>> export type GenerateAiJournalApiJournalsGenerateAiPostMutationBody = JournalGenerateRequest export type GenerateAiJournalApiJournalsGenerateAiPostMutationError = HTTPValidationError /** * @summary Generate Ai Journal */ export const useGenerateAiJournalApiJournalsGenerateAiPost = (options?: { mutation?:UseMutationOptions>, TError,{data: JournalGenerateRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: JournalGenerateRequest}, TContext > => { return useMutation(getGenerateAiJournalApiJournalsGenerateAiPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/logs/logs.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { GetLogContentApiLogsContentGetParams, HTTPValidationError } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取日志文件列表 * @summary Get Log Files */ export type getLogFilesApiLogsFilesGetResponse200 = { data: unknown status: 200 } export type getLogFilesApiLogsFilesGetResponseSuccess = (getLogFilesApiLogsFilesGetResponse200) & { headers: Headers; }; ; export type getLogFilesApiLogsFilesGetResponse = (getLogFilesApiLogsFilesGetResponseSuccess) export const getGetLogFilesApiLogsFilesGetUrl = () => { return `/api/logs/files` } export const getLogFilesApiLogsFilesGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetLogFilesApiLogsFilesGetUrl(), { ...options, method: 'GET' } );} export const getGetLogFilesApiLogsFilesGetQueryKey = () => { return [ `/api/logs/files` ] as const; } export const getGetLogFilesApiLogsFilesGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetLogFilesApiLogsFilesGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getLogFilesApiLogsFilesGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetLogFilesApiLogsFilesGetQueryResult = NonNullable>> export type GetLogFilesApiLogsFilesGetQueryError = unknown export function useGetLogFilesApiLogsFilesGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetLogFilesApiLogsFilesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetLogFilesApiLogsFilesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Log Files */ export function useGetLogFilesApiLogsFilesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetLogFilesApiLogsFilesGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取日志文件内容 * @summary Get Log Content */ export type getLogContentApiLogsContentGetResponse200 = { data: string status: 200 } export type getLogContentApiLogsContentGetResponse422 = { data: HTTPValidationError status: 422 } export type getLogContentApiLogsContentGetResponseSuccess = (getLogContentApiLogsContentGetResponse200) & { headers: Headers; }; export type getLogContentApiLogsContentGetResponseError = (getLogContentApiLogsContentGetResponse422) & { headers: Headers; }; export type getLogContentApiLogsContentGetResponse = (getLogContentApiLogsContentGetResponseSuccess | getLogContentApiLogsContentGetResponseError) export const getGetLogContentApiLogsContentGetUrl = (params: GetLogContentApiLogsContentGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/logs/content?${stringifiedParams}` : `/api/logs/content` } export const getLogContentApiLogsContentGet = async (params: GetLogContentApiLogsContentGetParams, options?: RequestInit): Promise => { return customFetcher(getGetLogContentApiLogsContentGetUrl(params), { ...options, method: 'GET' } );} export const getGetLogContentApiLogsContentGetQueryKey = (params?: GetLogContentApiLogsContentGetParams,) => { return [ `/api/logs/content`, ...(params ? [params] : []) ] as const; } export const getGetLogContentApiLogsContentGetQueryOptions = >, TError = HTTPValidationError>(params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetLogContentApiLogsContentGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getLogContentApiLogsContentGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetLogContentApiLogsContentGetQueryResult = NonNullable>> export type GetLogContentApiLogsContentGetQueryError = HTTPValidationError export function useGetLogContentApiLogsContentGet>, TError = HTTPValidationError>( params: GetLogContentApiLogsContentGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetLogContentApiLogsContentGet>, TError = HTTPValidationError>( params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetLogContentApiLogsContentGet>, TError = HTTPValidationError>( params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Log Content */ export function useGetLogContentApiLogsContentGet>, TError = HTTPValidationError>( params: GetLogContentApiLogsContentGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetLogContentApiLogsContentGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/notifications/notifications.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { HTTPValidationError } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取通知列表(按时间倒序) 返回格式: [ { "id": "通知ID", "title": "通知标题", "content": "通知内容", "timestamp": "时间戳(ISO格式)", "todo_id": 待办ID(可选) } ] * @summary Get Notification */ export type getNotificationApiNotificationsGetResponse200 = { data: unknown status: 200 } export type getNotificationApiNotificationsGetResponseSuccess = (getNotificationApiNotificationsGetResponse200) & { headers: Headers; }; ; export type getNotificationApiNotificationsGetResponse = (getNotificationApiNotificationsGetResponseSuccess) export const getGetNotificationApiNotificationsGetUrl = () => { return `/api/notifications` } export const getNotificationApiNotificationsGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetNotificationApiNotificationsGetUrl(), { ...options, method: 'GET' } );} export const getGetNotificationApiNotificationsGetQueryKey = () => { return [ `/api/notifications` ] as const; } export const getGetNotificationApiNotificationsGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetNotificationApiNotificationsGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getNotificationApiNotificationsGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetNotificationApiNotificationsGetQueryResult = NonNullable>> export type GetNotificationApiNotificationsGetQueryError = unknown export function useGetNotificationApiNotificationsGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetNotificationApiNotificationsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetNotificationApiNotificationsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Notification */ export function useGetNotificationApiNotificationsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetNotificationApiNotificationsGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 删除指定通知 Args: notification_id: 通知ID Returns: {"success": True, "message": "通知已删除"} * @summary Delete Notification */ export type deleteNotificationApiNotificationsNotificationIdDeleteResponse200 = { data: unknown status: 200 } export type deleteNotificationApiNotificationsNotificationIdDeleteResponse422 = { data: HTTPValidationError status: 422 } export type deleteNotificationApiNotificationsNotificationIdDeleteResponseSuccess = (deleteNotificationApiNotificationsNotificationIdDeleteResponse200) & { headers: Headers; }; export type deleteNotificationApiNotificationsNotificationIdDeleteResponseError = (deleteNotificationApiNotificationsNotificationIdDeleteResponse422) & { headers: Headers; }; export type deleteNotificationApiNotificationsNotificationIdDeleteResponse = (deleteNotificationApiNotificationsNotificationIdDeleteResponseSuccess | deleteNotificationApiNotificationsNotificationIdDeleteResponseError) export const getDeleteNotificationApiNotificationsNotificationIdDeleteUrl = (notificationId: string,) => { return `/api/notifications/${notificationId}` } export const deleteNotificationApiNotificationsNotificationIdDelete = async (notificationId: string, options?: RequestInit): Promise => { return customFetcher(getDeleteNotificationApiNotificationsNotificationIdDeleteUrl(notificationId), { ...options, method: 'DELETE' } );} export const getDeleteNotificationApiNotificationsNotificationIdDeleteMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{notificationId: string}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{notificationId: string}, TContext> => { const mutationKey = ['deleteNotificationApiNotificationsNotificationIdDelete']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {notificationId: string}> = (props) => { const {notificationId} = props ?? {}; return deleteNotificationApiNotificationsNotificationIdDelete(notificationId,requestOptions) } return { mutationFn, ...mutationOptions }} export type DeleteNotificationApiNotificationsNotificationIdDeleteMutationResult = NonNullable>> export type DeleteNotificationApiNotificationsNotificationIdDeleteMutationError = HTTPValidationError /** * @summary Delete Notification */ export const useDeleteNotificationApiNotificationsNotificationIdDelete = (options?: { mutation?:UseMutationOptions>, TError,{notificationId: string}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {notificationId: string}, TContext > => { return useMutation(getDeleteNotificationApiNotificationsNotificationIdDeleteMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/ocr/ocr.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { HTTPValidationError, ProcessOcrApiOcrProcessPostParams } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 手动触发OCR处理 * @summary Process Ocr */ export type processOcrApiOcrProcessPostResponse200 = { data: unknown status: 200 } export type processOcrApiOcrProcessPostResponse422 = { data: HTTPValidationError status: 422 } export type processOcrApiOcrProcessPostResponseSuccess = (processOcrApiOcrProcessPostResponse200) & { headers: Headers; }; export type processOcrApiOcrProcessPostResponseError = (processOcrApiOcrProcessPostResponse422) & { headers: Headers; }; export type processOcrApiOcrProcessPostResponse = (processOcrApiOcrProcessPostResponseSuccess | processOcrApiOcrProcessPostResponseError) export const getProcessOcrApiOcrProcessPostUrl = (params: ProcessOcrApiOcrProcessPostParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/ocr/process?${stringifiedParams}` : `/api/ocr/process` } export const processOcrApiOcrProcessPost = async (params: ProcessOcrApiOcrProcessPostParams, options?: RequestInit): Promise => { return customFetcher(getProcessOcrApiOcrProcessPostUrl(params), { ...options, method: 'POST' } );} export const getProcessOcrApiOcrProcessPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{params: ProcessOcrApiOcrProcessPostParams}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{params: ProcessOcrApiOcrProcessPostParams}, TContext> => { const mutationKey = ['processOcrApiOcrProcessPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {params: ProcessOcrApiOcrProcessPostParams}> = (props) => { const {params} = props ?? {}; return processOcrApiOcrProcessPost(params,requestOptions) } return { mutationFn, ...mutationOptions }} export type ProcessOcrApiOcrProcessPostMutationResult = NonNullable>> export type ProcessOcrApiOcrProcessPostMutationError = HTTPValidationError /** * @summary Process Ocr */ export const useProcessOcrApiOcrProcessPost = (options?: { mutation?:UseMutationOptions>, TError,{params: ProcessOcrApiOcrProcessPostParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {params: ProcessOcrApiOcrProcessPostParams}, TContext > => { return useMutation(getProcessOcrApiOcrProcessPostMutationOptions(options), queryClient); } /** * 获取OCR处理统计 * @summary Get Ocr Statistics */ export type getOcrStatisticsApiOcrStatisticsGetResponse200 = { data: unknown status: 200 } export type getOcrStatisticsApiOcrStatisticsGetResponseSuccess = (getOcrStatisticsApiOcrStatisticsGetResponse200) & { headers: Headers; }; ; export type getOcrStatisticsApiOcrStatisticsGetResponse = (getOcrStatisticsApiOcrStatisticsGetResponseSuccess) export const getGetOcrStatisticsApiOcrStatisticsGetUrl = () => { return `/api/ocr/statistics` } export const getOcrStatisticsApiOcrStatisticsGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetOcrStatisticsApiOcrStatisticsGetUrl(), { ...options, method: 'GET' } );} export const getGetOcrStatisticsApiOcrStatisticsGetQueryKey = () => { return [ `/api/ocr/statistics` ] as const; } export const getGetOcrStatisticsApiOcrStatisticsGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetOcrStatisticsApiOcrStatisticsGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getOcrStatisticsApiOcrStatisticsGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetOcrStatisticsApiOcrStatisticsGetQueryResult = NonNullable>> export type GetOcrStatisticsApiOcrStatisticsGetQueryError = unknown export function useGetOcrStatisticsApiOcrStatisticsGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetOcrStatisticsApiOcrStatisticsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetOcrStatisticsApiOcrStatisticsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Ocr Statistics */ export function useGetOcrStatisticsApiOcrStatisticsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetOcrStatisticsApiOcrStatisticsGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/proactive-ocr/proactive-ocr.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 启动主动OCR监控服务 * @summary Start Proactive Ocr */ export type startProactiveOcrApiProactiveOcrStartPostResponse200 = { data: unknown status: 200 } export type startProactiveOcrApiProactiveOcrStartPostResponseSuccess = (startProactiveOcrApiProactiveOcrStartPostResponse200) & { headers: Headers; }; ; export type startProactiveOcrApiProactiveOcrStartPostResponse = (startProactiveOcrApiProactiveOcrStartPostResponseSuccess) export const getStartProactiveOcrApiProactiveOcrStartPostUrl = () => { return `/api/proactive-ocr/start` } export const startProactiveOcrApiProactiveOcrStartPost = async ( options?: RequestInit): Promise => { return customFetcher(getStartProactiveOcrApiProactiveOcrStartPostUrl(), { ...options, method: 'POST' } );} export const getStartProactiveOcrApiProactiveOcrStartPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,void, TContext> => { const mutationKey = ['startProactiveOcrApiProactiveOcrStartPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, void> = () => { return startProactiveOcrApiProactiveOcrStartPost(requestOptions) } return { mutationFn, ...mutationOptions }} export type StartProactiveOcrApiProactiveOcrStartPostMutationResult = NonNullable>> export type StartProactiveOcrApiProactiveOcrStartPostMutationError = unknown /** * @summary Start Proactive Ocr */ export const useStartProactiveOcrApiProactiveOcrStartPost = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, void, TContext > => { return useMutation(getStartProactiveOcrApiProactiveOcrStartPostMutationOptions(options), queryClient); } /** * 停止主动OCR监控服务 * @summary Stop Proactive Ocr */ export type stopProactiveOcrApiProactiveOcrStopPostResponse200 = { data: unknown status: 200 } export type stopProactiveOcrApiProactiveOcrStopPostResponseSuccess = (stopProactiveOcrApiProactiveOcrStopPostResponse200) & { headers: Headers; }; ; export type stopProactiveOcrApiProactiveOcrStopPostResponse = (stopProactiveOcrApiProactiveOcrStopPostResponseSuccess) export const getStopProactiveOcrApiProactiveOcrStopPostUrl = () => { return `/api/proactive-ocr/stop` } export const stopProactiveOcrApiProactiveOcrStopPost = async ( options?: RequestInit): Promise => { return customFetcher(getStopProactiveOcrApiProactiveOcrStopPostUrl(), { ...options, method: 'POST' } );} export const getStopProactiveOcrApiProactiveOcrStopPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,void, TContext> => { const mutationKey = ['stopProactiveOcrApiProactiveOcrStopPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, void> = () => { return stopProactiveOcrApiProactiveOcrStopPost(requestOptions) } return { mutationFn, ...mutationOptions }} export type StopProactiveOcrApiProactiveOcrStopPostMutationResult = NonNullable>> export type StopProactiveOcrApiProactiveOcrStopPostMutationError = unknown /** * @summary Stop Proactive Ocr */ export const useStopProactiveOcrApiProactiveOcrStopPost = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, void, TContext > => { return useMutation(getStopProactiveOcrApiProactiveOcrStopPostMutationOptions(options), queryClient); } /** * 手动触发一次捕获和OCR处理 * @summary Capture Once */ export type captureOnceApiProactiveOcrCapturePostResponse200 = { data: unknown status: 200 } export type captureOnceApiProactiveOcrCapturePostResponseSuccess = (captureOnceApiProactiveOcrCapturePostResponse200) & { headers: Headers; }; ; export type captureOnceApiProactiveOcrCapturePostResponse = (captureOnceApiProactiveOcrCapturePostResponseSuccess) export const getCaptureOnceApiProactiveOcrCapturePostUrl = () => { return `/api/proactive-ocr/capture` } export const captureOnceApiProactiveOcrCapturePost = async ( options?: RequestInit): Promise => { return customFetcher(getCaptureOnceApiProactiveOcrCapturePostUrl(), { ...options, method: 'POST' } );} export const getCaptureOnceApiProactiveOcrCapturePostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,void, TContext> => { const mutationKey = ['captureOnceApiProactiveOcrCapturePost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, void> = () => { return captureOnceApiProactiveOcrCapturePost(requestOptions) } return { mutationFn, ...mutationOptions }} export type CaptureOnceApiProactiveOcrCapturePostMutationResult = NonNullable>> export type CaptureOnceApiProactiveOcrCapturePostMutationError = unknown /** * @summary Capture Once */ export const useCaptureOnceApiProactiveOcrCapturePost = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, void, TContext > => { return useMutation(getCaptureOnceApiProactiveOcrCapturePostMutationOptions(options), queryClient); } /** * 获取主动OCR服务状态 * @summary Get Proactive Ocr Status */ export type getProactiveOcrStatusApiProactiveOcrStatusGetResponse200 = { data: unknown status: 200 } export type getProactiveOcrStatusApiProactiveOcrStatusGetResponseSuccess = (getProactiveOcrStatusApiProactiveOcrStatusGetResponse200) & { headers: Headers; }; ; export type getProactiveOcrStatusApiProactiveOcrStatusGetResponse = (getProactiveOcrStatusApiProactiveOcrStatusGetResponseSuccess) export const getGetProactiveOcrStatusApiProactiveOcrStatusGetUrl = () => { return `/api/proactive-ocr/status` } export const getProactiveOcrStatusApiProactiveOcrStatusGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetProactiveOcrStatusApiProactiveOcrStatusGetUrl(), { ...options, method: 'GET' } );} export const getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryKey = () => { return [ `/api/proactive-ocr/status` ] as const; } export const getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getProactiveOcrStatusApiProactiveOcrStatusGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetProactiveOcrStatusApiProactiveOcrStatusGetQueryResult = NonNullable>> export type GetProactiveOcrStatusApiProactiveOcrStatusGetQueryError = unknown export function useGetProactiveOcrStatusApiProactiveOcrStatusGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetProactiveOcrStatusApiProactiveOcrStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetProactiveOcrStatusApiProactiveOcrStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Proactive Ocr Status */ export function useGetProactiveOcrStatusApiProactiveOcrStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetProactiveOcrStatusApiProactiveOcrStatusGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 健康检查 * @summary Health Check */ export type healthCheckApiProactiveOcrHealthGetResponse200 = { data: unknown status: 200 } export type healthCheckApiProactiveOcrHealthGetResponseSuccess = (healthCheckApiProactiveOcrHealthGetResponse200) & { headers: Headers; }; ; export type healthCheckApiProactiveOcrHealthGetResponse = (healthCheckApiProactiveOcrHealthGetResponseSuccess) export const getHealthCheckApiProactiveOcrHealthGetUrl = () => { return `/api/proactive-ocr/health` } export const healthCheckApiProactiveOcrHealthGet = async ( options?: RequestInit): Promise => { return customFetcher(getHealthCheckApiProactiveOcrHealthGetUrl(), { ...options, method: 'GET' } );} export const getHealthCheckApiProactiveOcrHealthGetQueryKey = () => { return [ `/api/proactive-ocr/health` ] as const; } export const getHealthCheckApiProactiveOcrHealthGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getHealthCheckApiProactiveOcrHealthGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => healthCheckApiProactiveOcrHealthGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type HealthCheckApiProactiveOcrHealthGetQueryResult = NonNullable>> export type HealthCheckApiProactiveOcrHealthGetQueryError = unknown export function useHealthCheckApiProactiveOcrHealthGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useHealthCheckApiProactiveOcrHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useHealthCheckApiProactiveOcrHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Health Check */ export function useHealthCheckApiProactiveOcrHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getHealthCheckApiProactiveOcrHealthGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/rag/rag.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { HTTPValidationError } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * RAG服务健康检查 * @summary Rag Health Check */ export type ragHealthCheckApiRagHealthGetResponse200 = { data: unknown status: 200 } export type ragHealthCheckApiRagHealthGetResponseSuccess = (ragHealthCheckApiRagHealthGetResponse200) & { headers: Headers; }; ; export type ragHealthCheckApiRagHealthGetResponse = (ragHealthCheckApiRagHealthGetResponseSuccess) export const getRagHealthCheckApiRagHealthGetUrl = () => { return `/api/rag/health` } export const ragHealthCheckApiRagHealthGet = async ( options?: RequestInit): Promise => { return customFetcher(getRagHealthCheckApiRagHealthGetUrl(), { ...options, method: 'GET' } );} export const getRagHealthCheckApiRagHealthGetQueryKey = () => { return [ `/api/rag/health` ] as const; } export const getRagHealthCheckApiRagHealthGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getRagHealthCheckApiRagHealthGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => ragHealthCheckApiRagHealthGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type RagHealthCheckApiRagHealthGetQueryResult = NonNullable>> export type RagHealthCheckApiRagHealthGetQueryError = unknown export function useRagHealthCheckApiRagHealthGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useRagHealthCheckApiRagHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useRagHealthCheckApiRagHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Rag Health Check */ export function useRagHealthCheckApiRagHealthGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getRagHealthCheckApiRagHealthGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取应用图标 根据映射表返回对应的图标文件 Args: app_name: 应用名称 Returns: 图标文件 * @summary Get App Icon */ export type getAppIconApiAppIconAppNameGetResponse200 = { data: unknown status: 200 } export type getAppIconApiAppIconAppNameGetResponse422 = { data: HTTPValidationError status: 422 } export type getAppIconApiAppIconAppNameGetResponseSuccess = (getAppIconApiAppIconAppNameGetResponse200) & { headers: Headers; }; export type getAppIconApiAppIconAppNameGetResponseError = (getAppIconApiAppIconAppNameGetResponse422) & { headers: Headers; }; export type getAppIconApiAppIconAppNameGetResponse = (getAppIconApiAppIconAppNameGetResponseSuccess | getAppIconApiAppIconAppNameGetResponseError) export const getGetAppIconApiAppIconAppNameGetUrl = (appName: string,) => { return `/api/app-icon/${appName}` } export const getAppIconApiAppIconAppNameGet = async (appName: string, options?: RequestInit): Promise => { return customFetcher(getGetAppIconApiAppIconAppNameGetUrl(appName), { ...options, method: 'GET' } );} export const getGetAppIconApiAppIconAppNameGetQueryKey = (appName: string,) => { return [ `/api/app-icon/${appName}` ] as const; } export const getGetAppIconApiAppIconAppNameGetQueryOptions = >, TError = HTTPValidationError>(appName: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetAppIconApiAppIconAppNameGetQueryKey(appName); const queryFn: QueryFunction>> = ({ signal }) => getAppIconApiAppIconAppNameGet(appName, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(appName), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetAppIconApiAppIconAppNameGetQueryResult = NonNullable>> export type GetAppIconApiAppIconAppNameGetQueryError = HTTPValidationError export function useGetAppIconApiAppIconAppNameGet>, TError = HTTPValidationError>( appName: string, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetAppIconApiAppIconAppNameGet>, TError = HTTPValidationError>( appName: string, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetAppIconApiAppIconAppNameGet>, TError = HTTPValidationError>( appName: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get App Icon */ export function useGetAppIconApiAppIconAppNameGet>, TError = HTTPValidationError>( appName: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetAppIconApiAppIconAppNameGetQueryOptions(appName,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/scheduler/scheduler.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { HTTPValidationError, JobInfo, JobIntervalUpdateRequest, JobListResponse, JobOperationResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取所有定时任务 * @summary Get All Jobs */ export type getAllJobsApiSchedulerJobsGetResponse200 = { data: JobListResponse status: 200 } export type getAllJobsApiSchedulerJobsGetResponseSuccess = (getAllJobsApiSchedulerJobsGetResponse200) & { headers: Headers; }; ; export type getAllJobsApiSchedulerJobsGetResponse = (getAllJobsApiSchedulerJobsGetResponseSuccess) export const getGetAllJobsApiSchedulerJobsGetUrl = () => { return `/api/scheduler/jobs` } export const getAllJobsApiSchedulerJobsGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetAllJobsApiSchedulerJobsGetUrl(), { ...options, method: 'GET' } );} export const getGetAllJobsApiSchedulerJobsGetQueryKey = () => { return [ `/api/scheduler/jobs` ] as const; } export const getGetAllJobsApiSchedulerJobsGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetAllJobsApiSchedulerJobsGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getAllJobsApiSchedulerJobsGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetAllJobsApiSchedulerJobsGetQueryResult = NonNullable>> export type GetAllJobsApiSchedulerJobsGetQueryError = unknown export function useGetAllJobsApiSchedulerJobsGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetAllJobsApiSchedulerJobsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetAllJobsApiSchedulerJobsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get All Jobs */ export function useGetAllJobsApiSchedulerJobsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetAllJobsApiSchedulerJobsGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取指定任务的详细信息 * @summary Get Job Detail */ export type getJobDetailApiSchedulerJobsJobIdGetResponse200 = { data: JobInfo status: 200 } export type getJobDetailApiSchedulerJobsJobIdGetResponse422 = { data: HTTPValidationError status: 422 } export type getJobDetailApiSchedulerJobsJobIdGetResponseSuccess = (getJobDetailApiSchedulerJobsJobIdGetResponse200) & { headers: Headers; }; export type getJobDetailApiSchedulerJobsJobIdGetResponseError = (getJobDetailApiSchedulerJobsJobIdGetResponse422) & { headers: Headers; }; export type getJobDetailApiSchedulerJobsJobIdGetResponse = (getJobDetailApiSchedulerJobsJobIdGetResponseSuccess | getJobDetailApiSchedulerJobsJobIdGetResponseError) export const getGetJobDetailApiSchedulerJobsJobIdGetUrl = (jobId: string,) => { return `/api/scheduler/jobs/${jobId}` } export const getJobDetailApiSchedulerJobsJobIdGet = async (jobId: string, options?: RequestInit): Promise => { return customFetcher(getGetJobDetailApiSchedulerJobsJobIdGetUrl(jobId), { ...options, method: 'GET' } );} export const getGetJobDetailApiSchedulerJobsJobIdGetQueryKey = (jobId: string,) => { return [ `/api/scheduler/jobs/${jobId}` ] as const; } export const getGetJobDetailApiSchedulerJobsJobIdGetQueryOptions = >, TError = HTTPValidationError>(jobId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetJobDetailApiSchedulerJobsJobIdGetQueryKey(jobId); const queryFn: QueryFunction>> = ({ signal }) => getJobDetailApiSchedulerJobsJobIdGet(jobId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(jobId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetJobDetailApiSchedulerJobsJobIdGetQueryResult = NonNullable>> export type GetJobDetailApiSchedulerJobsJobIdGetQueryError = HTTPValidationError export function useGetJobDetailApiSchedulerJobsJobIdGet>, TError = HTTPValidationError>( jobId: string, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetJobDetailApiSchedulerJobsJobIdGet>, TError = HTTPValidationError>( jobId: string, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetJobDetailApiSchedulerJobsJobIdGet>, TError = HTTPValidationError>( jobId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Job Detail */ export function useGetJobDetailApiSchedulerJobsJobIdGet>, TError = HTTPValidationError>( jobId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetJobDetailApiSchedulerJobsJobIdGetQueryOptions(jobId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 删除指定任务 * @summary Remove Job */ export type removeJobApiSchedulerJobsJobIdDeleteResponse200 = { data: JobOperationResponse status: 200 } export type removeJobApiSchedulerJobsJobIdDeleteResponse422 = { data: HTTPValidationError status: 422 } export type removeJobApiSchedulerJobsJobIdDeleteResponseSuccess = (removeJobApiSchedulerJobsJobIdDeleteResponse200) & { headers: Headers; }; export type removeJobApiSchedulerJobsJobIdDeleteResponseError = (removeJobApiSchedulerJobsJobIdDeleteResponse422) & { headers: Headers; }; export type removeJobApiSchedulerJobsJobIdDeleteResponse = (removeJobApiSchedulerJobsJobIdDeleteResponseSuccess | removeJobApiSchedulerJobsJobIdDeleteResponseError) export const getRemoveJobApiSchedulerJobsJobIdDeleteUrl = (jobId: string,) => { return `/api/scheduler/jobs/${jobId}` } export const removeJobApiSchedulerJobsJobIdDelete = async (jobId: string, options?: RequestInit): Promise => { return customFetcher(getRemoveJobApiSchedulerJobsJobIdDeleteUrl(jobId), { ...options, method: 'DELETE' } );} export const getRemoveJobApiSchedulerJobsJobIdDeleteMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{jobId: string}, TContext> => { const mutationKey = ['removeJobApiSchedulerJobsJobIdDelete']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {jobId: string}> = (props) => { const {jobId} = props ?? {}; return removeJobApiSchedulerJobsJobIdDelete(jobId,requestOptions) } return { mutationFn, ...mutationOptions }} export type RemoveJobApiSchedulerJobsJobIdDeleteMutationResult = NonNullable>> export type RemoveJobApiSchedulerJobsJobIdDeleteMutationError = HTTPValidationError /** * @summary Remove Job */ export const useRemoveJobApiSchedulerJobsJobIdDelete = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {jobId: string}, TContext > => { return useMutation(getRemoveJobApiSchedulerJobsJobIdDeleteMutationOptions(options), queryClient); } /** * 暂停指定任务 * @summary Pause Job */ export type pauseJobApiSchedulerJobsJobIdPausePostResponse200 = { data: JobOperationResponse status: 200 } export type pauseJobApiSchedulerJobsJobIdPausePostResponse422 = { data: HTTPValidationError status: 422 } export type pauseJobApiSchedulerJobsJobIdPausePostResponseSuccess = (pauseJobApiSchedulerJobsJobIdPausePostResponse200) & { headers: Headers; }; export type pauseJobApiSchedulerJobsJobIdPausePostResponseError = (pauseJobApiSchedulerJobsJobIdPausePostResponse422) & { headers: Headers; }; export type pauseJobApiSchedulerJobsJobIdPausePostResponse = (pauseJobApiSchedulerJobsJobIdPausePostResponseSuccess | pauseJobApiSchedulerJobsJobIdPausePostResponseError) export const getPauseJobApiSchedulerJobsJobIdPausePostUrl = (jobId: string,) => { return `/api/scheduler/jobs/${jobId}/pause` } export const pauseJobApiSchedulerJobsJobIdPausePost = async (jobId: string, options?: RequestInit): Promise => { return customFetcher(getPauseJobApiSchedulerJobsJobIdPausePostUrl(jobId), { ...options, method: 'POST' } );} export const getPauseJobApiSchedulerJobsJobIdPausePostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{jobId: string}, TContext> => { const mutationKey = ['pauseJobApiSchedulerJobsJobIdPausePost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {jobId: string}> = (props) => { const {jobId} = props ?? {}; return pauseJobApiSchedulerJobsJobIdPausePost(jobId,requestOptions) } return { mutationFn, ...mutationOptions }} export type PauseJobApiSchedulerJobsJobIdPausePostMutationResult = NonNullable>> export type PauseJobApiSchedulerJobsJobIdPausePostMutationError = HTTPValidationError /** * @summary Pause Job */ export const usePauseJobApiSchedulerJobsJobIdPausePost = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {jobId: string}, TContext > => { return useMutation(getPauseJobApiSchedulerJobsJobIdPausePostMutationOptions(options), queryClient); } /** * 恢复指定任务 * @summary Resume Job */ export type resumeJobApiSchedulerJobsJobIdResumePostResponse200 = { data: JobOperationResponse status: 200 } export type resumeJobApiSchedulerJobsJobIdResumePostResponse422 = { data: HTTPValidationError status: 422 } export type resumeJobApiSchedulerJobsJobIdResumePostResponseSuccess = (resumeJobApiSchedulerJobsJobIdResumePostResponse200) & { headers: Headers; }; export type resumeJobApiSchedulerJobsJobIdResumePostResponseError = (resumeJobApiSchedulerJobsJobIdResumePostResponse422) & { headers: Headers; }; export type resumeJobApiSchedulerJobsJobIdResumePostResponse = (resumeJobApiSchedulerJobsJobIdResumePostResponseSuccess | resumeJobApiSchedulerJobsJobIdResumePostResponseError) export const getResumeJobApiSchedulerJobsJobIdResumePostUrl = (jobId: string,) => { return `/api/scheduler/jobs/${jobId}/resume` } export const resumeJobApiSchedulerJobsJobIdResumePost = async (jobId: string, options?: RequestInit): Promise => { return customFetcher(getResumeJobApiSchedulerJobsJobIdResumePostUrl(jobId), { ...options, method: 'POST' } );} export const getResumeJobApiSchedulerJobsJobIdResumePostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{jobId: string}, TContext> => { const mutationKey = ['resumeJobApiSchedulerJobsJobIdResumePost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {jobId: string}> = (props) => { const {jobId} = props ?? {}; return resumeJobApiSchedulerJobsJobIdResumePost(jobId,requestOptions) } return { mutationFn, ...mutationOptions }} export type ResumeJobApiSchedulerJobsJobIdResumePostMutationResult = NonNullable>> export type ResumeJobApiSchedulerJobsJobIdResumePostMutationError = HTTPValidationError /** * @summary Resume Job */ export const useResumeJobApiSchedulerJobsJobIdResumePost = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {jobId: string}, TContext > => { return useMutation(getResumeJobApiSchedulerJobsJobIdResumePostMutationOptions(options), queryClient); } /** * 更新任务执行间隔 * @summary Update Job Interval */ export type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse200 = { data: JobOperationResponse status: 200 } export type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse422 = { data: HTTPValidationError status: 422 } export type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseSuccess = (updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse200) & { headers: Headers; }; export type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseError = (updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse422) & { headers: Headers; }; export type updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponse = (updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseSuccess | updateJobIntervalApiSchedulerJobsJobIdIntervalPutResponseError) export const getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutUrl = (jobId: string,) => { return `/api/scheduler/jobs/${jobId}/interval` } export const updateJobIntervalApiSchedulerJobsJobIdIntervalPut = async (jobId: string, jobIntervalUpdateRequest: JobIntervalUpdateRequest, options?: RequestInit): Promise => { return customFetcher(getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutUrl(jobId), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( jobIntervalUpdateRequest,) } );} export const getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string;data: JobIntervalUpdateRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{jobId: string;data: JobIntervalUpdateRequest}, TContext> => { const mutationKey = ['updateJobIntervalApiSchedulerJobsJobIdIntervalPut']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {jobId: string;data: JobIntervalUpdateRequest}> = (props) => { const {jobId,data} = props ?? {}; return updateJobIntervalApiSchedulerJobsJobIdIntervalPut(jobId,data,requestOptions) } return { mutationFn, ...mutationOptions }} export type UpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationResult = NonNullable>> export type UpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationBody = JobIntervalUpdateRequest export type UpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationError = HTTPValidationError /** * @summary Update Job Interval */ export const useUpdateJobIntervalApiSchedulerJobsJobIdIntervalPut = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string;data: JobIntervalUpdateRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {jobId: string;data: JobIntervalUpdateRequest}, TContext > => { return useMutation(getUpdateJobIntervalApiSchedulerJobsJobIdIntervalPutMutationOptions(options), queryClient); } /** * 获取调度器状态 * @summary Get Scheduler Status */ export type getSchedulerStatusApiSchedulerStatusGetResponse200 = { data: unknown status: 200 } export type getSchedulerStatusApiSchedulerStatusGetResponseSuccess = (getSchedulerStatusApiSchedulerStatusGetResponse200) & { headers: Headers; }; ; export type getSchedulerStatusApiSchedulerStatusGetResponse = (getSchedulerStatusApiSchedulerStatusGetResponseSuccess) export const getGetSchedulerStatusApiSchedulerStatusGetUrl = () => { return `/api/scheduler/status` } export const getSchedulerStatusApiSchedulerStatusGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetSchedulerStatusApiSchedulerStatusGetUrl(), { ...options, method: 'GET' } );} export const getGetSchedulerStatusApiSchedulerStatusGetQueryKey = () => { return [ `/api/scheduler/status` ] as const; } export const getGetSchedulerStatusApiSchedulerStatusGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetSchedulerStatusApiSchedulerStatusGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getSchedulerStatusApiSchedulerStatusGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetSchedulerStatusApiSchedulerStatusGetQueryResult = NonNullable>> export type GetSchedulerStatusApiSchedulerStatusGetQueryError = unknown export function useGetSchedulerStatusApiSchedulerStatusGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetSchedulerStatusApiSchedulerStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetSchedulerStatusApiSchedulerStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Scheduler Status */ export function useGetSchedulerStatusApiSchedulerStatusGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetSchedulerStatusApiSchedulerStatusGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 暂停所有任务 * @summary Pause All Jobs */ export type pauseAllJobsApiSchedulerJobsPauseAllPostResponse200 = { data: JobOperationResponse status: 200 } export type pauseAllJobsApiSchedulerJobsPauseAllPostResponseSuccess = (pauseAllJobsApiSchedulerJobsPauseAllPostResponse200) & { headers: Headers; }; ; export type pauseAllJobsApiSchedulerJobsPauseAllPostResponse = (pauseAllJobsApiSchedulerJobsPauseAllPostResponseSuccess) export const getPauseAllJobsApiSchedulerJobsPauseAllPostUrl = () => { return `/api/scheduler/jobs/pause-all` } export const pauseAllJobsApiSchedulerJobsPauseAllPost = async ( options?: RequestInit): Promise => { return customFetcher(getPauseAllJobsApiSchedulerJobsPauseAllPostUrl(), { ...options, method: 'POST' } );} export const getPauseAllJobsApiSchedulerJobsPauseAllPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,void, TContext> => { const mutationKey = ['pauseAllJobsApiSchedulerJobsPauseAllPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, void> = () => { return pauseAllJobsApiSchedulerJobsPauseAllPost(requestOptions) } return { mutationFn, ...mutationOptions }} export type PauseAllJobsApiSchedulerJobsPauseAllPostMutationResult = NonNullable>> export type PauseAllJobsApiSchedulerJobsPauseAllPostMutationError = unknown /** * @summary Pause All Jobs */ export const usePauseAllJobsApiSchedulerJobsPauseAllPost = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, void, TContext > => { return useMutation(getPauseAllJobsApiSchedulerJobsPauseAllPostMutationOptions(options), queryClient); } /** * 恢复所有任务 * @summary Resume All Jobs */ export type resumeAllJobsApiSchedulerJobsResumeAllPostResponse200 = { data: JobOperationResponse status: 200 } export type resumeAllJobsApiSchedulerJobsResumeAllPostResponseSuccess = (resumeAllJobsApiSchedulerJobsResumeAllPostResponse200) & { headers: Headers; }; ; export type resumeAllJobsApiSchedulerJobsResumeAllPostResponse = (resumeAllJobsApiSchedulerJobsResumeAllPostResponseSuccess) export const getResumeAllJobsApiSchedulerJobsResumeAllPostUrl = () => { return `/api/scheduler/jobs/resume-all` } export const resumeAllJobsApiSchedulerJobsResumeAllPost = async ( options?: RequestInit): Promise => { return customFetcher(getResumeAllJobsApiSchedulerJobsResumeAllPostUrl(), { ...options, method: 'POST' } );} export const getResumeAllJobsApiSchedulerJobsResumeAllPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,void, TContext> => { const mutationKey = ['resumeAllJobsApiSchedulerJobsResumeAllPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, void> = () => { return resumeAllJobsApiSchedulerJobsResumeAllPost(requestOptions) } return { mutationFn, ...mutationOptions }} export type ResumeAllJobsApiSchedulerJobsResumeAllPostMutationResult = NonNullable>> export type ResumeAllJobsApiSchedulerJobsResumeAllPostMutationError = unknown /** * @summary Resume All Jobs */ export const useResumeAllJobsApiSchedulerJobsResumeAllPost = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, void, TContext > => { return useMutation(getResumeAllJobsApiSchedulerJobsResumeAllPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/schemas/activityEventsResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ActivityEventsResponse { event_ids: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/activityListResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ActivityResponse } from './activityResponse'; export interface ActivityListResponse { activities: ActivityResponse[]; total_count: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/activityResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ActivityResponse { id: number; start_time: string; end_time: string; ai_title?: string | null; ai_summary?: string | null; event_count: number; created_at?: string | null; updated_at?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/activityResponseAiSummary.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ActivityResponseAiSummary = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/activityResponseAiTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ActivityResponseAiTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/activityResponseCreatedAt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ActivityResponseCreatedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/activityResponseUpdatedAt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ActivityResponseUpdatedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/addMessageRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface AddMessageRequest { role: string; content: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/audioLinkItem.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface AudioLinkItem { /** todo|schedule */ kind: string; /** extracted item id */ item_id: string; /** linked todo id */ todo_id: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/audioLinkRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { AudioLinkItem } from './audioLinkItem'; export interface AudioLinkRequest { links: AudioLinkItem[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/bodyImportIcsApiTodosImportIcsPost.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface BodyImportIcsApiTodosImportIcsPost { file: Blob; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost { /** 附件列表 */ files: Blob[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/capabilitiesResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { CapabilitiesResponseMissingDeps } from './capabilitiesResponseMissingDeps'; export interface CapabilitiesResponse { enabled_modules: string[]; available_modules: string[]; disabled_modules: string[]; missing_deps: CapabilitiesResponseMissingDeps; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/capabilitiesResponseMissingDeps.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type CapabilitiesResponseMissingDeps = {[key: string]: string[]}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessage.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ChatMessage { message: string; user_input?: string | null; context?: string | null; system_prompt?: string | null; conversation_id?: string | null; use_rag?: boolean; mode?: string | null; selected_tools?: string[] | null; external_tools?: string[] | null; workspace_path?: string | null; enable_file_delete?: boolean; [key: string]: unknown; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageContext.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageContext = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageConversationId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageConversationId = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageExternalTools.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageExternalTools = string[] | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageMode.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageMode = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageProjectId.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageProjectId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageSelectedTools.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageSelectedTools = string[] | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageSystemPrompt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageSystemPrompt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageTaskIds.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageTaskIds = number[] | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageUserInput.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageUserInput = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageWithContext.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ChatMessageWithContextEventContext } from './chatMessageWithContextEventContext'; export interface ChatMessageWithContext { message: string; conversation_id?: string | null; event_context?: ChatMessageWithContextEventContext; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageWithContextConversationId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageWithContextConversationId = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageWithContextEventContext.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ChatMessageWithContextEventContext = { [key: string]: unknown }[] | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageWithContextEventContextAnyOfItem.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageWithContextEventContextAnyOfItem = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatMessageWorkspacePath.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatMessageWorkspacePath = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ChatResponsePerformance } from './chatResponsePerformance'; import type { ChatResponseQueryInfo } from './chatResponseQueryInfo'; import type { ChatResponseRetrievalInfo } from './chatResponseRetrievalInfo'; export interface ChatResponse { response: string; timestamp: string; query_info?: ChatResponseQueryInfo; retrieval_info?: ChatResponseRetrievalInfo; performance?: ChatResponsePerformance; session_id?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponsePerformance.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ChatResponsePerformance = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponsePerformanceAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatResponsePerformanceAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponseQueryInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ChatResponseQueryInfo = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponseQueryInfoAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatResponseQueryInfoAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponseRetrievalInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ChatResponseRetrievalInfo = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponseRetrievalInfoAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatResponseRetrievalInfoAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/chatResponseSessionId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ChatResponseSessionId = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/cleanupOldDataApiCleanupPostParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type CleanupOldDataApiCleanupPostParams = { /** * @minimum 1 */ days?: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextListResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ContextResponse } from "./contextResponse"; /** * 上下文列表响应模型 */ export interface ContextListResponse { /** 总数 */ total: number; /** 上下文列表 */ contexts: ContextResponse[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ContextResponseAiSummary } from "./contextResponseAiSummary"; import type { ContextResponseAiTitle } from "./contextResponseAiTitle"; import type { ContextResponseAppName } from "./contextResponseAppName"; import type { ContextResponseCreatedAt } from "./contextResponseCreatedAt"; import type { ContextResponseEndTime } from "./contextResponseEndTime"; import type { ContextResponseProjectId } from "./contextResponseProjectId"; import type { ContextResponseStartTime } from "./contextResponseStartTime"; import type { ContextResponseTaskId } from "./contextResponseTaskId"; import type { ContextResponseWindowTitle } from "./contextResponseWindowTitle"; /** * 上下文响应模型 */ export interface ContextResponse { /** 上下文ID */ id: number; /** 应用名称 */ app_name?: ContextResponseAppName; /** 窗口标题 */ window_title?: ContextResponseWindowTitle; /** 开始时间 */ start_time?: ContextResponseStartTime; /** 结束时间 */ end_time?: ContextResponseEndTime; /** AI生成的标题 */ ai_title?: ContextResponseAiTitle; /** AI生成的摘要 */ ai_summary?: ContextResponseAiSummary; /** 关联的项目ID */ project_id?: ContextResponseProjectId; /** 关联的任务ID */ task_id?: ContextResponseTaskId; /** 创建时间 */ created_at?: ContextResponseCreatedAt; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseAiSummary.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * AI生成的摘要 */ export type ContextResponseAiSummary = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseAiTitle.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * AI生成的标题 */ export type ContextResponseAiTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseAppName.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 应用名称 */ export type ContextResponseAppName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseCreatedAt.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 创建时间 */ export type ContextResponseCreatedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseEndTime.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 结束时间 */ export type ContextResponseEndTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseProjectId.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 关联的项目ID */ export type ContextResponseProjectId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseStartTime.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 开始时间 */ export type ContextResponseStartTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseTaskId.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 关联的任务ID */ export type ContextResponseTaskId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextResponseWindowTitle.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 窗口标题 */ export type ContextResponseWindowTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextUpdateRequest.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ContextUpdateRequestProjectId } from "./contextUpdateRequestProjectId"; import type { ContextUpdateRequestTaskId } from "./contextUpdateRequestTaskId"; /** * 上下文更新请求模型 */ export interface ContextUpdateRequest { /** 关联的项目ID(可选) */ project_id?: ContextUpdateRequestProjectId; /** 关联的任务ID(null表示解除关联) */ task_id?: ContextUpdateRequestTaskId; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextUpdateRequestProjectId.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 关联的项目ID(可选) */ export type ContextUpdateRequestProjectId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/contextUpdateRequestTaskId.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 关联的任务ID(null表示解除关联) */ export type ContextUpdateRequestTaskId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/countEventsApiEventsCountGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type CountEventsApiEventsCountGetParams = { start_date?: string | null; end_date?: string | null; app_name?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/createdTodo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 创建的待办项 */ export interface CreatedTodo { /** 待办 ID */ id: number; /** 待办名称 */ name: string; /** 计划时间 */ scheduled_time?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/createdTodoScheduledTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 计划时间 */ export type CreatedTodoScheduledTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventDetailResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ScreenshotResponse } from './screenshotResponse'; export interface EventDetailResponse { id: number; app_name: string | null; window_title: string | null; start_time: string; end_time: string | null; screenshots: ScreenshotResponse[]; ai_title?: string | null; ai_summary?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventDetailResponseAiSummary.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventDetailResponseAiSummary = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventDetailResponseAiTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventDetailResponseAiTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventDetailResponseAppName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventDetailResponseAppName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventDetailResponseEndTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventDetailResponseEndTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventDetailResponseWindowTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventDetailResponseWindowTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventListResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { EventResponse } from './eventResponse'; /** * 事件列表响应,包含事件列表和总数 */ export interface EventListResponse { events: EventResponse[]; total_count: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface EventResponse { id: number; app_name: string | null; window_title: string | null; start_time: string; end_time: string | null; screenshot_count: number; first_screenshot_id: number | null; ai_title?: string | null; ai_summary?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventResponseAiSummary.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventResponseAiSummary = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventResponseAiTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventResponseAiTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventResponseAppName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventResponseAppName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventResponseEndTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventResponseEndTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventResponseFirstScreenshotId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventResponseFirstScreenshotId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/eventResponseWindowTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type EventResponseWindowTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/exportIcsApiTodosExportIcsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ExportIcsApiTodosExportIcsGetParams = { /** * 导出数量限制 * @minimum 1 * @maximum 2000 */ limit?: number; /** * 导出偏移量 * @minimum 0 */ offset?: number; /** * 状态筛选:active/completed/canceled */ status?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractTodosAndSchedulesApiAudioExtractPostParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ExtractTodosAndSchedulesApiAudioExtractPostParams = { recording_id: number; optimized?: boolean; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedMessageTodo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 从消息中提取的待办项结构 */ export interface ExtractedMessageTodo { /** * 待办名称 * @minLength 1 * @maxLength 100 */ name: string; /** 待办描述(可选) */ description?: string | null; /** 标签列表 */ tags?: string[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedMessageTodoDescription.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 待办描述(可选) */ export type ExtractedMessageTodoDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedTodo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoTimeInfo } from './todoTimeInfo'; /** * 提取的待办项结构 */ export interface ExtractedTodo { /** * 待办标题 * @minLength 1 * @maxLength 100 */ title: string; /** 待办描述(可选) */ description?: string | null; /** 时间信息 */ time_info: TodoTimeInfo; /** 解析后的绝对时间(程序计算得出) */ scheduled_time?: string | null; /** 来源文本片段,用于验证 */ source_text: string; /** 置信度(0.0-1.0),可选 */ confidence?: number | null; /** 相关的截图ID列表 */ screenshot_ids?: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedTodoConfidence.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 置信度(0.0-1.0),可选 */ export type ExtractedTodoConfidence = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedTodoDescription.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 待办描述(可选) */ export type ExtractedTodoDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedTodoScheduledTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 解析后的绝对时间(程序计算得出) */ export type ExtractedTodoScheduledTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedTodoSourceText.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 来源文本 */ export type ExtractedTodoSourceText = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedTodoTimeInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 时间信息 */ export type ExtractedTodoTimeInfo = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/extractedTodoTimeInfoAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ExtractedTodoTimeInfoAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/floatingCaptureRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 悬浮窗截图请求模型 */ export interface FloatingCaptureRequest { /** Base64 编码的截图数据(不含 data:image/png;base64, 前缀) */ image_base64: string; /** 是否自动创建待办(draft 状态) */ create_todos?: boolean; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/floatingCaptureResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { CreatedTodo } from './createdTodo'; import type { LifetraceSchemasFloatingCaptureExtractedTodo } from './lifetraceSchemasFloatingCaptureExtractedTodo'; /** * 悬浮窗截图响应模型 */ export interface FloatingCaptureResponse { /** 是否成功 */ success: boolean; /** 处理消息 */ message: string; /** 提取的待办列表 */ extracted_todos?: LifetraceSchemasFloatingCaptureExtractedTodo[]; /** 创建的待办列表 */ created_todos?: CreatedTodo[]; /** 创建的待办数量 */ created_count?: number; /** 响应时间戳 */ timestamp?: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/generateTasksResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { GeneratedTaskItem } from "./generatedTaskItem"; /** * AI任务拆解响应模型 */ export interface GenerateTasksResponse { /** 生成的任务列表 */ tasks: GeneratedTaskItem[]; /** 操作结果消息 */ message: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/generatedTaskItem.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { GeneratedTaskItemDescription } from "./generatedTaskItemDescription"; /** * AI生成的任务项 */ export interface GeneratedTaskItem { /** 任务ID */ id: number; /** 任务名称 */ name: string; /** 任务描述 */ description?: GeneratedTaskItemDescription; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/generatedTaskItemDescription.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 任务描述 */ export type GeneratedTaskItemDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getChatHistoryApiChatHistoryGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetChatHistoryApiChatHistoryGetParams = { session_id?: string | null; /** * 聊天类型过滤:event, project, general */ chat_type?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getChatPromptsApiGetChatPromptsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetChatPromptsApiGetChatPromptsGetParams = { locale?: string; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getContextsApiContextsGetParams.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type GetContextsApiContextsGetParams = { /** * 是否已关联任务(true/false) */ associated?: boolean | null; /** * 按任务ID过滤 */ task_id?: number | null; /** * 返回数量限制 * @minimum 1 * @maximum 1000 */ limit?: number; /** * 偏移量 * @minimum 0 */ offset?: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getCostStatsApiCostTrackingStatsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetCostStatsApiCostTrackingStatsGetParams = { /** * 统计天数 */ days?: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getLogContentApiLogsContentGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetLogContentApiLogsContentGetParams = { /** * 日志文件相对路径 */ file: string; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getProjectTasksApiProjectsProjectIdTasksGetParams.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type GetProjectTasksApiProjectsProjectIdTasksGetParams = { /** * 返回数量限制 * @minimum 1 * @maximum 1000 */ limit?: number; /** * 偏移量 * @minimum 0 */ offset?: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getProjectsApiProjectsGetParams.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type GetProjectsApiProjectsGetParams = { /** * 返回数量限制 * @minimum 1 * @maximum 1000 */ limit?: number; /** * 偏移量 * @minimum 0 */ offset?: number; /** * 项目状态筛选(active/archived/completed) */ status?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getQuerySuggestionsApiChatSuggestionsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetQuerySuggestionsApiChatSuggestionsGetParams = { /** * 部分查询文本 */ partial_query?: string; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getRecordingsApiAudioRecordingsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetRecordingsApiAudioRecordingsGetParams = { date?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getScreenshotsApiScreenshotsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetScreenshotsApiScreenshotsGetParams = { /** * @minimum 1 * @maximum 200 */ limit?: number; /** * @minimum 0 */ offset?: number; start_date?: string | null; end_date?: string | null; app_name?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getTaskProgressApiProjectsProjectIdTasksTaskIdProgressGetParams.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type GetTaskProgressApiProjectsProjectIdTasksTaskIdProgressGetParams = { /** * 返回数量限制 * @minimum 1 * @maximum 100 */ limit?: number; /** * 偏移量 * @minimum 0 */ offset?: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getTaskProgressLatestApiProjectsProjectIdTasksTaskIdProgressLatestGet200.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TaskProgressResponse } from "./taskProgressResponse"; export type GetTaskProgressLatestApiProjectsProjectIdTasksTaskIdProgressLatestGet200 = TaskProgressResponse | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getTimeAllocationApiTimeAllocationGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetTimeAllocationApiTimeAllocationGetParams = { /** * 开始日期, YYYY-MM-DD 格式 */ start_date?: string | null; /** * 结束日期, YYYY-MM-DD 格式 */ end_date?: string | null; /** * 统计天数 (弃用, 仅用于兼容) * @minimum 1 * @maximum 365 */ days?: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getTimelineApiAudioTimelineGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetTimelineApiAudioTimelineGetParams = { date?: string | null; optimized?: boolean; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/getTranscriptionApiAudioTranscriptionRecordingIdGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type GetTranscriptionApiAudioTranscriptionRecordingIdGetParams = { optimized?: boolean; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/hTTPValidationError.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ValidationError } from './validationError'; export interface HTTPValidationError { detail?: ValidationError[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/index.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export * from './activityEventsResponse'; export * from './activityListResponse'; export * from './activityResponse'; export * from './activityResponseAiSummary'; export * from './activityResponseAiTitle'; export * from './activityResponseCreatedAt'; export * from './activityResponseUpdatedAt'; export * from './addMessageRequest'; export * from './audioLinkItem'; export * from './audioLinkRequest'; export * from './bodyImportIcsApiTodosImportIcsPost'; export * from './bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost'; export * from './capabilitiesResponse'; export * from './capabilitiesResponseMissingDeps'; export * from './chatMessage'; export * from './chatMessageContext'; export * from './chatMessageConversationId'; export * from './chatMessageExternalTools'; export * from './chatMessageMode'; export * from './chatMessageProjectId'; export * from './chatMessageSelectedTools'; export * from './chatMessageSystemPrompt'; export * from './chatMessageTaskIds'; export * from './chatMessageUserInput'; export * from './chatMessageWithContext'; export * from './chatMessageWithContextConversationId'; export * from './chatMessageWithContextEventContext'; export * from './chatMessageWithContextEventContextAnyOfItem'; export * from './chatMessageWorkspacePath'; export * from './chatResponse'; export * from './chatResponsePerformance'; export * from './chatResponsePerformanceAnyOf'; export * from './chatResponseQueryInfo'; export * from './chatResponseQueryInfoAnyOf'; export * from './chatResponseRetrievalInfo'; export * from './chatResponseRetrievalInfoAnyOf'; export * from './chatResponseSessionId'; export * from './cleanupOldDataApiCleanupPostParams'; export * from './contextListResponse'; export * from './contextResponse'; export * from './contextResponseAiSummary'; export * from './contextResponseAiTitle'; export * from './contextResponseAppName'; export * from './contextResponseCreatedAt'; export * from './contextResponseEndTime'; export * from './contextResponseProjectId'; export * from './contextResponseStartTime'; export * from './contextResponseTaskId'; export * from './contextResponseWindowTitle'; export * from './contextUpdateRequest'; export * from './contextUpdateRequestProjectId'; export * from './contextUpdateRequestTaskId'; export * from './countEventsApiEventsCountGetParams'; export * from './createdTodo'; export * from './createdTodoScheduledTime'; export * from './eventDetailResponse'; export * from './eventDetailResponseAiSummary'; export * from './eventDetailResponseAiTitle'; export * from './eventDetailResponseAppName'; export * from './eventDetailResponseEndTime'; export * from './eventDetailResponseWindowTitle'; export * from './eventListResponse'; export * from './eventResponse'; export * from './eventResponseAiSummary'; export * from './eventResponseAiTitle'; export * from './eventResponseAppName'; export * from './eventResponseEndTime'; export * from './eventResponseFirstScreenshotId'; export * from './eventResponseWindowTitle'; export * from './exportIcsApiTodosExportIcsGetParams'; export * from './extractedMessageTodo'; export * from './extractedMessageTodoDescription'; export * from './extractedTodo'; export * from './extractedTodoConfidence'; export * from './extractedTodoDescription'; export * from './extractedTodoScheduledTime'; export * from './extractedTodoSourceText'; export * from './extractedTodoTimeInfo'; export * from './extractedTodoTimeInfoAnyOf'; export * from './extractTodosAndSchedulesApiAudioExtractPostParams'; export * from './floatingCaptureRequest'; export * from './floatingCaptureResponse'; export * from './generatedTaskItem'; export * from './generatedTaskItemDescription'; export * from './generateTasksResponse'; export * from './getChatHistoryApiChatHistoryGetParams'; export * from './getChatPromptsApiGetChatPromptsGetParams'; export * from './getContextsApiContextsGetParams'; export * from './getCostStatsApiCostTrackingStatsGetParams'; export * from './getLogContentApiLogsContentGetParams'; export * from './getProjectsApiProjectsGetParams'; export * from './getProjectTasksApiProjectsProjectIdTasksGetParams'; export * from './getQuerySuggestionsApiChatSuggestionsGetParams'; export * from './getRecordingsApiAudioRecordingsGetParams'; export * from './getScreenshotsApiScreenshotsGetParams'; export * from './getTaskProgressApiProjectsProjectIdTasksTaskIdProgressGetParams'; export * from './getTaskProgressLatestApiProjectsProjectIdTasksTaskIdProgressLatestGet200'; export * from './getTimeAllocationApiTimeAllocationGetParams'; export * from './getTimelineApiAudioTimelineGetParams'; export * from './getTranscriptionApiAudioTranscriptionRecordingIdGetParams'; export * from './hTTPValidationError'; export * from './jobInfo'; export * from './jobInfoName'; export * from './jobInfoNextRunTime'; export * from './jobIntervalUpdateRequest'; export * from './jobIntervalUpdateRequestHours'; export * from './jobIntervalUpdateRequestMinutes'; export * from './jobIntervalUpdateRequestSeconds'; export * from './jobListResponse'; export * from './jobOperationResponse'; export * from './journalAutoLinkCandidate'; export * from './journalAutoLinkRequest'; export * from './journalAutoLinkResponse'; export * from './journalCreate'; export * from './journalGenerateRequest'; export * from './journalGenerateResponse'; export * from './journalListResponse'; export * from './journalResponse'; export * from './journalResponseDeletedAt'; export * from './journalTag'; export * from './journalUpdate'; export * from './journalUpdateContentFormat'; export * from './journalUpdateDate'; export * from './journalUpdateName'; export * from './journalUpdateTagIds'; export * from './journalUpdateUserNotes'; export * from './lifetraceSchemasFloatingCaptureExtractedTodo'; export * from './lifetraceSchemasFloatingCaptureExtractedTodoDescription'; export * from './lifetraceSchemasFloatingCaptureExtractedTodoSourceText'; export * from './lifetraceSchemasFloatingCaptureExtractedTodoTimeInfo'; export * from './lifetraceSchemasFloatingCaptureExtractedTodoTimeInfoAnyOf'; export * from './lifetraceSchemasTodoExtractionExtractedTodo'; export * from './lifetraceSchemasTodoExtractionExtractedTodoConfidence'; export * from './lifetraceSchemasTodoExtractionExtractedTodoDescription'; export * from './lifetraceSchemasTodoExtractionExtractedTodoScheduledTime'; export * from './linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams'; export * from './listActivitiesApiActivitiesGetParams'; export * from './listEventsApiEventsGetParams'; export * from './listJournalsApiJournalsGetParams'; export * from './listTodosApiTodosGetParams'; export * from './manualActivityCreateRequest'; export * from './manualActivityCreateResponse'; export * from './manualActivityCreateResponseAiSummary'; export * from './manualActivityCreateResponseAiTitle'; export * from './manualActivityCreateResponseCreatedAt'; export * from './messageTodoExtractionRequest'; export * from './messageTodoExtractionRequestMessagesItem'; export * from './messageTodoExtractionRequestParentTodoId'; export * from './messageTodoExtractionRequestTodoContext'; export * from './messageTodoExtractionResponse'; export * from './messageTodoExtractionResponseErrorMessage'; export * from './newChatRequest'; export * from './newChatRequestSessionId'; export * from './newChatResponse'; export * from './optimizeTranscriptionApiAudioOptimizePostParams'; export * from './planQuestionnaireRequest'; export * from './planQuestionnaireRequestSessionId'; export * from './planQuestionnaireRequestTodoId'; export * from './planSummaryRequest'; export * from './planSummaryRequestAnswers'; export * from './planSummaryRequestSessionId'; export * from './processInfo'; export * from './processOcrApiOcrProcessPostParams'; export * from './projectCreate'; export * from './projectCreateDefinitionOfDone'; export * from './projectCreateDescription'; export * from './projectListResponse'; export * from './projectResponse'; export * from './projectResponseDefinitionOfDone'; export * from './projectResponseDescription'; export * from './projectStatus'; export * from './projectUpdate'; export * from './projectUpdateDefinitionOfDone'; export * from './projectUpdateDescription'; export * from './projectUpdateName'; export * from './projectUpdateStatus'; export * from './saveAndInitLlmApiSaveAndInitLlmPostBody'; export * from './saveConfigApiSaveConfigPostBody'; export * from './screenshotResponse'; export * from './screenshotResponseAppName'; export * from './screenshotResponseTextContent'; export * from './screenshotResponseWindowTitle'; export * from './searchRequest'; export * from './searchRequestAppName'; export * from './searchRequestEndDate'; export * from './searchRequestQuery'; export * from './searchRequestStartDate'; export * from './semanticSearchRequest'; export * from './semanticSearchRequestFilters'; export * from './semanticSearchRequestFiltersAnyOf'; export * from './semanticSearchRequestRetrieveK'; export * from './semanticSearchResult'; export * from './semanticSearchResultMetadata'; export * from './semanticSearchResultOcrResult'; export * from './semanticSearchResultOcrResultAnyOf'; export * from './semanticSearchResultScreenshot'; export * from './semanticSearchResultScreenshotAnyOf'; export * from './statisticsResponse'; export * from './syncVectorDatabaseApiVectorSyncPostParams'; export * from './systemResourcesResponse'; export * from './systemResourcesResponseCpu'; export * from './systemResourcesResponseDisk'; export * from './systemResourcesResponseMemory'; export * from './systemResourcesResponseStorage'; export * from './systemResourcesResponseSummary'; export * from './taskBatchDeleteRequest'; export * from './taskBatchDeleteResponse'; export * from './taskCreate'; export * from './taskCreateDescription'; export * from './taskListResponse'; export * from './taskProgressListResponse'; export * from './taskProgressResponse'; export * from './taskResponse'; export * from './taskResponseDescription'; export * from './taskStatus'; export * from './taskUpdate'; export * from './taskUpdateDescription'; export * from './taskUpdateName'; export * from './taskUpdateStatus'; export * from './testAsrConfigApiTestAsrConfigPostBody'; export * from './testLlmConfigApiTestLlmConfigPostBody'; export * from './testTavilyConfigApiTestTavilyConfigPostBody'; export * from './timeAllocationResponse'; export * from './timeAllocationResponseAppDetailsItem'; export * from './timeAllocationResponseDailyDistributionItem'; export * from './todoAttachmentResponse'; export * from './todoAttachmentResponseFileSize'; export * from './todoAttachmentResponseMimeType'; export * from './todoCreate'; export * from './todoCreateCompletedAt'; export * from './todoCreateDeadline'; export * from './todoCreateDescription'; export * from './todoCreateEndTime'; export * from './todoCreateParentTodoId'; export * from './todoCreatePercentComplete'; export * from './todoCreateRrule'; export * from './todoCreateStartTime'; export * from './todoCreateUid'; export * from './todoCreateUserNotes'; export * from './todoExtractionRequest'; export * from './todoExtractionRequestScreenshotSampleRatio'; export * from './todoExtractionResponse'; export * from './todoExtractionResponseAppName'; export * from './todoExtractionResponseErrorMessage'; export * from './todoExtractionResponseEventEndTime'; export * from './todoExtractionResponseEventStartTime'; export * from './todoExtractionResponseWindowTitle'; export * from './todoItemType'; export * from './todoListResponse'; export * from './todoPriority'; export * from './todoReorderItem'; export * from './todoReorderItemParentTodoId'; export * from './todoReorderRequest'; export * from './todoResponse'; export * from './todoResponseCompletedAt'; export * from './todoResponseDeadline'; export * from './todoResponseDescription'; export * from './todoResponseEndTime'; export * from './todoResponseParentTodoId'; export * from './todoResponseRrule'; export * from './todoResponseStartTime'; export * from './todoResponseUserNotes'; export * from './todoStatus'; export * from './todoTimeInfo'; export * from './todoTimeInfoAbsoluteTime'; export * from './todoTimeInfoRelativeDays'; export * from './todoTimeInfoRelativeTime'; export * from './todoTimeInfoTimeType'; export * from './todoUpdate'; export * from './todoUpdateCompletedAt'; export * from './todoUpdateDeadline'; export * from './todoUpdateDescription'; export * from './todoUpdateEndTime'; export * from './todoUpdateName'; export * from './todoUpdateOrder'; export * from './todoUpdateParentTodoId'; export * from './todoUpdatePercentComplete'; export * from './todoUpdatePriority'; export * from './todoUpdateRelatedActivities'; export * from './todoUpdateRrule'; export * from './todoUpdateStartTime'; export * from './todoUpdateStatus'; export * from './todoUpdateTags'; export * from './todoUpdateUserNotes'; export * from './updateJournalApiJournalsJournalIdPutBody'; export * from './validationError'; export * from './validationErrorLocItem'; export * from './vectorStatsResponse'; export * from './vectorStatsResponseCollectionName'; export * from './vectorStatsResponseDocumentCount'; export * from './vectorStatsResponseError'; export * from './visionChatRequest'; export * from './visionChatRequestMaxTokens'; export * from './visionChatRequestModel'; export * from './visionChatRequestTemperature'; export * from './visionChatResponse'; export * from './visionChatResponseModel'; export * from './visionChatResponseUsageInfo'; export * from './visionChatResponseUsageInfoAnyOf'; ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 任务信息 */ export interface JobInfo { id: string; name?: string | null; func: string; trigger: string; next_run_time?: string | null; pending?: boolean; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobInfoName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type JobInfoName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobInfoNextRunTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type JobInfoNextRunTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 任务间隔更新请求 */ export interface JobIntervalUpdateRequest { job_id: string; seconds?: number | null; minutes?: number | null; hours?: number | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequestHours.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type JobIntervalUpdateRequestHours = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequestMinutes.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type JobIntervalUpdateRequestMinutes = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobIntervalUpdateRequestSeconds.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type JobIntervalUpdateRequestSeconds = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobListResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { JobInfo } from './jobInfo'; /** * 任务列表响应 */ export interface JobListResponse { total: number; jobs: JobInfo[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/jobOperationResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 任务操作响应 */ export interface JobOperationResponse { success: boolean; message: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalAutoLinkCandidate.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 自动关联候选 */ export interface JournalAutoLinkCandidate { /** 候选ID */ id: number; /** 候选标题 */ name: string; /** 匹配分 */ score: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalAutoLinkRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 自动关联请求 */ export interface JournalAutoLinkRequest { /** 日记ID */ journal_id?: number | null; /** 日记标题 */ title?: string | null; /** 日记原文 */ content_original?: string | null; /** 日记日期 */ date: string; /** 日记归属刷新点 */ day_bucket_start?: string | null; /** * 默认关联数量 * @minimum 1 * @maximum 10 */ max_items?: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalAutoLinkResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { JournalAutoLinkCandidate } from './journalAutoLinkCandidate'; /** * 自动关联响应 */ export interface JournalAutoLinkResponse { /** 关联待办ID列表 */ related_todo_ids?: number[]; /** 关联活动ID列表 */ related_activity_ids?: number[]; /** 待办候选 */ todo_candidates?: JournalAutoLinkCandidate[]; /** 活动候选 */ activity_candidates?: JournalAutoLinkCandidate[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalCreate.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 创建日记请求模型 */ export interface JournalCreate { /** iCalendar UID */ uid?: string | null; /** 日记标题 */ name?: string | null; /** 日记内容(富文本) */ user_notes: string; /** 日记日期 */ date: string; /** * 内容格式:markdown/html/json * @maxLength 20 */ content_format?: string; /** 客观记录 */ content_objective?: string | null; /** AI 视角 */ content_ai?: string | null; /** 情绪 */ mood?: string | null; /** 精力 */ energy?: number | null; /** 日记归属刷新点 */ day_bucket_start?: string | null; /** 关联的标签列表 */ tags?: string[]; /** 关联待办ID列表 */ related_todo_ids?: number[]; /** 关联活动ID列表 */ related_activity_ids?: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalGenerateRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 生成客观记录/AI 视角请求 */ export interface JournalGenerateRequest { /** 日记ID */ journal_id?: number | null; /** 日记标题 */ title?: string | null; /** 日记原文 */ content_original?: string | null; /** 日记日期 */ date?: string | null; /** 日记归属刷新点 */ day_bucket_start?: string | null; /** * 语言 * @maxLength 10 */ language?: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalGenerateResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 生成结果响应 */ export interface JournalGenerateResponse { /** 生成内容 */ content: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalListResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { JournalResponse } from './journalResponse'; /** * 日记列表响应模型 */ export interface JournalListResponse { /** 总数 */ total: number; /** 日记列表 */ journals: JournalResponse[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { JournalTag } from './journalTag'; /** * 日记响应模型 */ export interface JournalResponse { /** 日记ID */ id: number; /** iCalendar UID */ uid: string; /** 日记标题 */ name: string; /** 日记内容(富文本) */ user_notes: string; /** 日记日期 */ date: string; /** 内容格式 */ content_format: string; /** 客观记录 */ content_objective?: string | null; /** AI 视角 */ content_ai?: string | null; /** 情绪 */ mood?: string | null; /** 精力 */ energy?: number | null; /** 日记归属刷新点 */ day_bucket_start?: string | null; /** 创建时间 */ created_at: string; /** 更新时间 */ updated_at: string; /** 删除时间 */ deleted_at?: string | null; /** 关联标签列表 */ tags?: JournalTag[]; /** 关联待办ID列表 */ related_todo_ids?: number[]; /** 关联活动ID列表 */ related_activity_ids?: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalResponseDeletedAt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 删除时间 */ export type JournalResponseDeletedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalTag.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 日记关联的标签 */ export interface JournalTag { /** 标签ID */ id: number; /** 标签名称 */ tag_name: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalUpdate.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 更新日记请求模型 */ export interface JournalUpdate { /** 日记标题 */ name?: string | null; /** 日记内容(富文本) */ user_notes?: string | null; /** 日记日期 */ date?: string | null; /** 内容格式:markdown/html/json */ content_format?: string | null; /** 客观记录 */ content_objective?: string | null; /** AI 视角 */ content_ai?: string | null; /** 情绪 */ mood?: string | null; /** 精力 */ energy?: number | null; /** 日记归属刷新点 */ day_bucket_start?: string | null; /** 关联的标签列表(覆盖替换) */ tags?: string[] | null; /** 关联待办ID列表 */ related_todo_ids?: number[] | null; /** 关联活动ID列表 */ related_activity_ids?: number[] | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalUpdateContentFormat.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 内容格式:markdown/html/json */ export type JournalUpdateContentFormat = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalUpdateDate.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 日记日期 */ export type JournalUpdateDate = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalUpdateName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 日记标题 */ export type JournalUpdateName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalUpdateTagIds.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 关联的标签ID列表(覆盖替换) */ export type JournalUpdateTagIds = number[] | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/journalUpdateUserNotes.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 日记内容(富文本) */ export type JournalUpdateUserNotes = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { LifetraceSchemasFloatingCaptureExtractedTodoTimeInfo } from './lifetraceSchemasFloatingCaptureExtractedTodoTimeInfo'; /** * 提取的待办项 */ export interface LifetraceSchemasFloatingCaptureExtractedTodo { /** 待办标题 */ title: string; /** 待办描述 */ description?: string | null; /** 时间信息 */ time_info?: LifetraceSchemasFloatingCaptureExtractedTodoTimeInfo; /** 来源文本 */ source_text?: string | null; /** * 置信度 * @minimum 0 * @maximum 1 */ confidence?: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoDescription.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 待办描述 */ export type LifetraceSchemasFloatingCaptureExtractedTodoDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoSourceText.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 来源文本 */ export type LifetraceSchemasFloatingCaptureExtractedTodoSourceText = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoTimeInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 时间信息 */ export type LifetraceSchemasFloatingCaptureExtractedTodoTimeInfo = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasFloatingCaptureExtractedTodoTimeInfoAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type LifetraceSchemasFloatingCaptureExtractedTodoTimeInfoAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoTimeInfo } from './todoTimeInfo'; /** * 提取的待办项结构 */ export interface LifetraceSchemasTodoExtractionExtractedTodo { /** * 待办标题 * @minLength 1 * @maxLength 100 */ title: string; /** 待办描述(可选) */ description?: string | null; /** 时间信息 */ time_info: TodoTimeInfo; /** 解析后的绝对时间(程序计算得出) */ scheduled_time?: string | null; /** 来源文本片段,用于验证 */ source_text: string; /** 置信度(0.0-1.0),可选 */ confidence?: number | null; /** 相关的截图ID列表 */ screenshot_ids?: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodoConfidence.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 置信度(0.0-1.0),可选 */ export type LifetraceSchemasTodoExtractionExtractedTodoConfidence = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodoDescription.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 待办描述(可选) */ export type LifetraceSchemasTodoExtractionExtractedTodoDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/lifetraceSchemasTodoExtractionExtractedTodoScheduledTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 解析后的绝对时间(程序计算得出) */ export type LifetraceSchemasTodoExtractionExtractedTodoScheduledTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/linkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type LinkExtractedItemsApiAudioTranscriptionRecordingIdLinkPostParams = { optimized?: boolean; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/listActivitiesApiActivitiesGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ListActivitiesApiActivitiesGetParams = { /** * @minimum 1 * @maximum 200 */ limit?: number; /** * @minimum 0 */ offset?: number; start_date?: string | null; end_date?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/listEventsApiEventsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ListEventsApiEventsGetParams = { /** * @minimum 1 * @maximum 200 */ limit?: number; /** * @minimum 0 */ offset?: number; start_date?: string | null; end_date?: string | null; app_name?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/listJournalsApiJournalsGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ListJournalsApiJournalsGetParams = { /** * 返回数量限制 * @minimum 1 * @maximum 1000 */ limit?: number; /** * 偏移量 * @minimum 0 */ offset?: number; /** * 开始日期筛选 */ start_date?: string | null; /** * 结束日期筛选 */ end_date?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/listTodosApiTodosGetParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ListTodosApiTodosGetParams = { /** * 返回数量限制 * @minimum 1 * @maximum 2000 */ limit?: number; /** * 偏移量 * @minimum 0 */ offset?: number; /** * 状态筛选:active/completed/canceled */ status?: string | null; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/manualActivityCreateRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ManualActivityCreateRequest { event_ids: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/manualActivityCreateResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ManualActivityCreateResponse { id: number; start_time: string; end_time: string; ai_title?: string | null; ai_summary?: string | null; event_count: number; created_at?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/manualActivityCreateResponseAiSummary.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ManualActivityCreateResponseAiSummary = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/manualActivityCreateResponseAiTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ManualActivityCreateResponseAiTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/manualActivityCreateResponseCreatedAt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ManualActivityCreateResponseCreatedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { MessageTodoExtractionRequestMessagesItem } from './messageTodoExtractionRequestMessagesItem'; /** * 从消息中提取待办的请求模型 */ export interface MessageTodoExtractionRequest { /** 消息列表,包含 role 和 content 字段 */ messages: MessageTodoExtractionRequestMessagesItem[]; /** 父待办ID,提取的待办将作为该待办的子待办 */ parent_todo_id?: number | null; /** 待办上下文信息,用于帮助AI理解关联的待办 */ todo_context?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequestMessagesItem.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type MessageTodoExtractionRequestMessagesItem = {[key: string]: string}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequestParentTodoId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 父待办ID,提取的待办将作为该待办的子待办 */ export type MessageTodoExtractionRequestParentTodoId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/messageTodoExtractionRequestTodoContext.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 待办上下文信息,用于帮助AI理解关联的待办 */ export type MessageTodoExtractionRequestTodoContext = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/messageTodoExtractionResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ExtractedMessageTodo } from './extractedMessageTodo'; /** * 从消息中提取待办的响应模型 */ export interface MessageTodoExtractionResponse { /** 提取的待办列表 */ todos?: ExtractedMessageTodo[]; /** 错误信息(如果有) */ error_message?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/messageTodoExtractionResponseErrorMessage.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 错误信息(如果有) */ export type MessageTodoExtractionResponseErrorMessage = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/newChatRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface NewChatRequest { session_id?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/newChatRequestSessionId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type NewChatRequestSessionId = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/newChatResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface NewChatResponse { session_id: string; message: string; timestamp: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/optimizeTranscriptionApiAudioOptimizePostParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type OptimizeTranscriptionApiAudioOptimizePostParams = { recording_id: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/planQuestionnaireRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface PlanQuestionnaireRequest { todo_name: string; todo_id?: number | null; session_id?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/planQuestionnaireRequestSessionId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type PlanQuestionnaireRequestSessionId = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/planQuestionnaireRequestTodoId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type PlanQuestionnaireRequestTodoId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/planSummaryRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { PlanSummaryRequestAnswers } from './planSummaryRequestAnswers'; export interface PlanSummaryRequest { todo_name: string; answers: PlanSummaryRequestAnswers; session_id?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/planSummaryRequestAnswers.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type PlanSummaryRequestAnswers = {[key: string]: string[]}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/planSummaryRequestSessionId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type PlanSummaryRequestSessionId = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/processInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ProcessInfo { pid: number; name: string; cmdline: string; memory_mb: number; memory_vms_mb: number; cpu_percent: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/processOcrApiOcrProcessPostParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type ProcessOcrApiOcrProcessPostParams = { screenshot_id: number; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectCreate.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ProjectCreateDefinitionOfDone } from "./projectCreateDefinitionOfDone"; import type { ProjectCreateDescription } from "./projectCreateDescription"; import type { ProjectStatus } from "./projectStatus"; /** * 创建项目请求模型 */ export interface ProjectCreate { /** * 项目名称 * @minLength 1 * @maxLength 200 */ name: string; /** 项目“完成”的定义 */ definition_of_done?: ProjectCreateDefinitionOfDone; /** 项目状态:active, archived, completed */ status?: ProjectStatus; /** 项目描述或为 AI Advisor 提供的系统级上下文摘要 */ description?: ProjectCreateDescription; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectCreateDefinitionOfDone.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目“完成”的定义 */ export type ProjectCreateDefinitionOfDone = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectCreateDescription.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目描述或为 AI Advisor 提供的系统级上下文摘要 */ export type ProjectCreateDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectListResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ProjectResponse } from "./projectResponse"; /** * 项目列表响应模型 */ export interface ProjectListResponse { /** 总数 */ total: number; /** 项目列表 */ projects: ProjectResponse[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ProjectResponseDefinitionOfDone } from "./projectResponseDefinitionOfDone"; import type { ProjectResponseDescription } from "./projectResponseDescription"; /** * 项目响应模型 */ export interface ProjectResponse { /** 项目ID */ id: number; /** 项目名称 */ name: string; /** 项目“完成”的定义 */ definition_of_done?: ProjectResponseDefinitionOfDone; /** 项目状态:active, archived, completed */ status: string; /** 项目描述或为 AI Advisor 提供的系统级上下文摘要 */ description?: ProjectResponseDescription; /** 创建时间 */ created_at: string; /** 更新时间 */ updated_at: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectResponseDefinitionOfDone.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目“完成”的定义 */ export type ProjectResponseDefinitionOfDone = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectResponseDescription.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目描述或为 AI Advisor 提供的系统级上下文摘要 */ export type ProjectResponseDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectStatus.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目状态枚举 */ export type ProjectStatus = (typeof ProjectStatus)[keyof typeof ProjectStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const ProjectStatus = { active: "active", archived: "archived", completed: "completed", } as const; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectUpdate.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ProjectUpdateDefinitionOfDone } from "./projectUpdateDefinitionOfDone"; import type { ProjectUpdateDescription } from "./projectUpdateDescription"; import type { ProjectUpdateName } from "./projectUpdateName"; import type { ProjectUpdateStatus } from "./projectUpdateStatus"; /** * 更新项目请求模型 */ export interface ProjectUpdate { /** 项目名称 */ name?: ProjectUpdateName; /** 项目“完成”的定义 */ definition_of_done?: ProjectUpdateDefinitionOfDone; /** 项目状态:active, archived, completed */ status?: ProjectUpdateStatus; /** 项目描述或为 AI Advisor 提供的系统级上下文摘要 */ description?: ProjectUpdateDescription; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectUpdateDefinitionOfDone.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目“完成”的定义 */ export type ProjectUpdateDefinitionOfDone = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectUpdateDescription.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目描述或为 AI Advisor 提供的系统级上下文摘要 */ export type ProjectUpdateDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectUpdateName.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 项目名称 */ export type ProjectUpdateName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/projectUpdateStatus.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { ProjectStatus } from "./projectStatus"; /** * 项目状态:active, archived, completed */ export type ProjectUpdateStatus = ProjectStatus | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/saveAndInitLlmApiSaveAndInitLlmPostBody.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SaveAndInitLlmApiSaveAndInitLlmPostBody = {[key: string]: string}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/saveConfigApiSaveConfigPostBody.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SaveConfigApiSaveConfigPostBody = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/screenshotResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ScreenshotResponse { id: number; file_path: string; app_name: string | null; window_title: string | null; created_at: string; text_content: string | null; width: number; height: number; file_deleted?: boolean; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/screenshotResponseAppName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ScreenshotResponseAppName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/screenshotResponseTextContent.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ScreenshotResponseTextContent = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/screenshotResponseWindowTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ScreenshotResponseWindowTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/searchRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface SearchRequest { query?: string | null; start_date?: string | null; end_date?: string | null; app_name?: string | null; limit?: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/searchRequestAppName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SearchRequestAppName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/searchRequestEndDate.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SearchRequestEndDate = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/searchRequestQuery.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SearchRequestQuery = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/searchRequestStartDate.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SearchRequestStartDate = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { SemanticSearchRequestFilters } from './semanticSearchRequestFilters'; export interface SemanticSearchRequest { query: string; top_k?: number; use_rerank?: boolean; retrieve_k?: number | null; filters?: SemanticSearchRequestFilters; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchRequestFilters.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SemanticSearchRequestFilters = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchRequestFiltersAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SemanticSearchRequestFiltersAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchRequestRetrieveK.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SemanticSearchRequestRetrieveK = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchResult.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { SemanticSearchResultMetadata } from './semanticSearchResultMetadata'; import type { SemanticSearchResultOcrResult } from './semanticSearchResultOcrResult'; import type { SemanticSearchResultScreenshot } from './semanticSearchResultScreenshot'; export interface SemanticSearchResult { text: string; score: number; metadata: SemanticSearchResultMetadata; ocr_result?: SemanticSearchResultOcrResult; screenshot?: SemanticSearchResultScreenshot; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchResultMetadata.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SemanticSearchResultMetadata = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchResultOcrResult.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SemanticSearchResultOcrResult = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchResultOcrResultAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SemanticSearchResultOcrResultAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchResultScreenshot.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SemanticSearchResultScreenshot = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/semanticSearchResultScreenshotAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type SemanticSearchResultScreenshotAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/statisticsResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface StatisticsResponse { total_screenshots: number; processed_screenshots: number; today_screenshots: number; processing_rate: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/syncVectorDatabaseApiVectorSyncPostParams.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SyncVectorDatabaseApiVectorSyncPostParams = { /** * 同步的最大记录数 */ limit?: number | null; /** * 是否强制重置向量数据库 */ force_reset?: boolean; }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/systemResourcesResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ProcessInfo } from './processInfo'; import type { SystemResourcesResponseCpu } from './systemResourcesResponseCpu'; import type { SystemResourcesResponseDisk } from './systemResourcesResponseDisk'; import type { SystemResourcesResponseMemory } from './systemResourcesResponseMemory'; import type { SystemResourcesResponseStorage } from './systemResourcesResponseStorage'; import type { SystemResourcesResponseSummary } from './systemResourcesResponseSummary'; export interface SystemResourcesResponse { memory: SystemResourcesResponseMemory; cpu: SystemResourcesResponseCpu; disk: SystemResourcesResponseDisk; lifetrace_processes: ProcessInfo[]; storage: SystemResourcesResponseStorage; summary: SystemResourcesResponseSummary; timestamp: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/systemResourcesResponseCpu.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SystemResourcesResponseCpu = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/systemResourcesResponseDisk.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SystemResourcesResponseDisk = {[key: string]: {[key: string]: number}}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/systemResourcesResponseMemory.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SystemResourcesResponseMemory = {[key: string]: number}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/systemResourcesResponseStorage.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SystemResourcesResponseStorage = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/systemResourcesResponseSummary.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type SystemResourcesResponseSummary = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskBatchDeleteRequest.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 批量删除任务请求模型 */ export interface TaskBatchDeleteRequest { /** * 要删除的任务ID列表 * @minItems 1 */ task_ids: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskBatchDeleteResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 批量删除任务响应模型 */ export interface TaskBatchDeleteResponse { /** 成功删除的任务数量 */ deleted_count: number; /** 删除失败的任务ID */ failed_ids?: number[]; /** 未找到的任务ID */ not_found_ids?: number[]; /** 不属于该项目的任务ID */ wrong_project_ids?: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskCreate.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TaskCreateDescription } from "./taskCreateDescription"; import type { TaskStatus } from "./taskStatus"; /** * 创建任务请求模型 */ export interface TaskCreate { /** * 任务名称 * @minLength 1 * @maxLength 200 */ name: string; /** 任务描述 */ description?: TaskCreateDescription; /** 任务状态 */ status?: TaskStatus; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskCreateDescription.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 任务描述 */ export type TaskCreateDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskListResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TaskResponse } from "./taskResponse"; /** * 任务列表响应模型 */ export interface TaskListResponse { /** 总数 */ total: number; /** 任务列表 */ tasks: TaskResponse[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskProgressListResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TaskProgressResponse } from "./taskProgressResponse"; /** * 任务进展列表响应模型 */ export interface TaskProgressListResponse { /** 总数 */ total: number; /** 进展记录列表 */ progress_list: TaskProgressResponse[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskProgressResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 任务进展响应模型 */ export interface TaskProgressResponse { /** 进展记录ID */ id: number; /** 任务ID */ task_id: number; /** 进展摘要内容 */ summary: string; /** 基于多少个上下文生成 */ context_count: number; /** 生成时间 */ generated_at: string; /** 创建时间 */ created_at: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskResponse.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TaskResponseDescription } from "./taskResponseDescription"; /** * 任务响应模型 */ export interface TaskResponse { /** 任务ID */ id: number; /** 项目ID */ project_id: number; /** 任务名称 */ name: string; /** 任务描述 */ description?: TaskResponseDescription; /** 任务状态 */ status: string; /** 创建时间 */ created_at: string; /** 更新时间 */ updated_at: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskResponseDescription.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 任务描述 */ export type TaskResponseDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskStatus.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 任务状态枚举 */ export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const TaskStatus = { pending: "pending", in_progress: "in_progress", completed: "completed", cancelled: "cancelled", } as const; ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskUpdate.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TaskUpdateDescription } from "./taskUpdateDescription"; import type { TaskUpdateName } from "./taskUpdateName"; import type { TaskUpdateStatus } from "./taskUpdateStatus"; /** * 更新任务请求模型 */ export interface TaskUpdate { /** 任务名称 */ name?: TaskUpdateName; /** 任务描述 */ description?: TaskUpdateDescription; /** 任务状态 */ status?: TaskUpdateStatus; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskUpdateDescription.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 任务描述 */ export type TaskUpdateDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskUpdateName.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 任务名称 */ export type TaskUpdateName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/taskUpdateStatus.ts ================================================ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TaskStatus } from "./taskStatus"; /** * 任务状态 */ export type TaskUpdateStatus = TaskStatus | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/testAsrConfigApiTestAsrConfigPostBody.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type TestAsrConfigApiTestAsrConfigPostBody = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/testLlmConfigApiTestLlmConfigPostBody.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type TestLlmConfigApiTestLlmConfigPostBody = {[key: string]: string}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/testTavilyConfigApiTestTavilyConfigPostBody.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type TestTavilyConfigApiTestTavilyConfigPostBody = {[key: string]: string}; ================================================ FILE: free-todo-frontend/lib/generated/schemas/timeAllocationResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TimeAllocationResponseAppDetailsItem } from './timeAllocationResponseAppDetailsItem'; import type { TimeAllocationResponseDailyDistributionItem } from './timeAllocationResponseDailyDistributionItem'; /** * 时间分配响应模型 */ export interface TimeAllocationResponse { total_time: number; daily_distribution: TimeAllocationResponseDailyDistributionItem[]; app_details: TimeAllocationResponseAppDetailsItem[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/timeAllocationResponseAppDetailsItem.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type TimeAllocationResponseAppDetailsItem = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/timeAllocationResponseDailyDistributionItem.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export type TimeAllocationResponseDailyDistributionItem = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoAttachmentResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * Todo 附件响应模型 */ export interface TodoAttachmentResponse { /** 附件ID */ id: number; /** 文件名 */ file_name: string; /** 文件路径 */ file_path: string; /** 文件大小(字节) */ file_size?: number | null; /** MIME 类型 */ mime_type?: string | null; /** 来源(user/ai) */ source?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoAttachmentResponseFileSize.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 文件大小(字节) */ export type TodoAttachmentResponseFileSize = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoAttachmentResponseMimeType.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * MIME 类型 */ export type TodoAttachmentResponseMimeType = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreate.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoItemType } from './todoItemType'; import type { TodoPriority } from './todoPriority'; import type { TodoStatus } from './todoStatus'; /** * 创建 Todo 请求模型 */ export interface TodoCreate { /** iCalendar UID */ uid?: string | null; /** * 待办名称 * @minLength 1 * @maxLength 200 */ name: string; /** iCalendar SUMMARY */ summary?: string | null; /** 描述 */ description?: string | null; /** 用户笔记 */ user_notes?: string | null; /** 父级待办ID */ parent_todo_id?: number | null; /** iCalendar 条目类型 */ item_type?: TodoItemType | null; /** iCalendar LOCATION */ location?: string | null; /** iCalendar CATEGORIES */ categories?: string | null; /** iCalendar CLASS */ classification?: string | null; /** 截止时间(旧字段,逐步废弃) */ deadline?: string | null; /** 开始时间 */ start_time?: string | null; /** 结束时间 */ end_time?: string | null; /** iCalendar DTSTART */ dtstart?: string | null; /** iCalendar DTEND */ dtend?: string | null; /** iCalendar DUE */ due?: string | null; /** iCalendar DURATION (ISO 8601) */ duration?: string | null; /** 时区(IANA) */ time_zone?: string | null; /** iCalendar TZID */ tzid?: string | null; /** 是否全天 */ is_all_day?: boolean | null; /** iCalendar DTSTAMP */ dtstamp?: string | null; /** iCalendar CREATED */ created?: string | null; /** iCalendar LAST-MODIFIED */ last_modified?: string | null; /** iCalendar SEQUENCE */ sequence?: number | null; /** iCalendar RDATE */ rdate?: string | null; /** iCalendar EXDATE */ exdate?: string | null; /** iCalendar RECURRENCE-ID */ recurrence_id?: string | null; /** iCalendar RELATED-TO UID */ related_to_uid?: string | null; /** iCalendar RELATED-TO RELTYPE */ related_to_reltype?: string | null; /** iCalendar STATUS */ ical_status?: string | null; /** 提醒偏移列表(分钟,基于 dtstart/due) */ reminder_offsets?: number[] | null; /** 状态 */ status?: TodoStatus; /** 优先级 */ priority?: TodoPriority; /** 完成时间 */ completed_at?: string | null; /** 完成百分比(0-100) */ percent_complete?: number | null; /** iCalendar RRULE */ rrule?: string | null; /** 同级待办之间的展示排序 */ order?: number; /** 标签名称列表 */ tags?: string[]; /** 关联活动ID列表 */ related_activities?: number[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateCompletedAt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 完成时间 */ export type TodoCreateCompletedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateDeadline.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 截止时间 */ export type TodoCreateDeadline = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateDescription.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 描述 */ export type TodoCreateDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateEndTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 结束时间 */ export type TodoCreateEndTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateParentTodoId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 父级待办ID */ export type TodoCreateParentTodoId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreatePercentComplete.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 完成百分比(0-100) */ export type TodoCreatePercentComplete = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateRrule.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * iCalendar RRULE */ export type TodoCreateRrule = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateStartTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 开始时间 */ export type TodoCreateStartTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateUid.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * iCalendar UID */ export type TodoCreateUid = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoCreateUserNotes.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 用户笔记 */ export type TodoCreateUserNotes = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 待办提取请求模型 */ export interface TodoExtractionRequest { /** * 事件ID * @exclusiveMinimum 0 */ event_id: number; /** 截图采样比例(每N张选1张),默认3 */ screenshot_sample_ratio?: number | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionRequestScreenshotSampleRatio.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 截图采样比例(每N张选1张),默认3 */ export type TodoExtractionRequestScreenshotSampleRatio = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { ExtractedTodo } from './extractedTodo'; /** * 待办提取响应模型 */ export interface TodoExtractionResponse { /** 事件ID */ event_id: number; /** 应用名称 */ app_name?: string | null; /** 窗口标题 */ window_title?: string | null; /** 事件开始时间 */ event_start_time?: string | null; /** 事件结束时间 */ event_end_time?: string | null; /** 提取的待办列表 */ todos?: ExtractedTodo[]; /** 提取时间戳 */ extraction_timestamp?: string; /** 实际分析的截图数量 */ screenshot_count?: number; /** 错误信息(如果有) */ error_message?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionResponseAppName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 应用名称 */ export type TodoExtractionResponseAppName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionResponseErrorMessage.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 错误信息(如果有) */ export type TodoExtractionResponseErrorMessage = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionResponseEventEndTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 事件结束时间 */ export type TodoExtractionResponseEventEndTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionResponseEventStartTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 事件开始时间 */ export type TodoExtractionResponseEventStartTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoExtractionResponseWindowTitle.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 窗口标题 */ export type TodoExtractionResponseWindowTitle = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoItemType.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * iCalendar 条目类型 */ export type TodoItemType = typeof TodoItemType[keyof typeof TodoItemType]; export const TodoItemType = { VTODO: 'VTODO', VEVENT: 'VEVENT', } as const; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoListResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoResponse } from './todoResponse'; /** * Todo 列表响应模型 */ export interface TodoListResponse { /** 总数 */ total: number; /** 待办列表 */ todos: TodoResponse[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoPriority.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * Todo 优先级(与前端保持一致) */ export type TodoPriority = typeof TodoPriority[keyof typeof TodoPriority]; export const TodoPriority = { high: 'high', medium: 'medium', low: 'low', none: 'none', } as const; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoReorderItem.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 单个待办排序项 */ export interface TodoReorderItem { /** 待办ID */ id: number; /** 新的排序值 */ order: number; /** 父级待办ID(可选,用于设置父子关系) */ parent_todo_id?: number | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoReorderItemParentTodoId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 父级待办ID(可选,用于设置父子关系) */ export type TodoReorderItemParentTodoId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoReorderRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoReorderItem } from './todoReorderItem'; /** * 批量重排序请求模型 */ export interface TodoReorderRequest { /** 待排序的待办列表 */ items: TodoReorderItem[]; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoAttachmentResponse } from './todoAttachmentResponse'; /** * Todo 响应模型 */ export interface TodoResponse { /** 待办ID */ id: number; /** iCalendar UID */ uid: string; /** 待办名称 */ name: string; /** iCalendar SUMMARY */ summary?: string | null; /** 描述 */ description?: string | null; /** 用户笔记 */ user_notes?: string | null; /** 父级待办ID */ parent_todo_id?: number | null; /** iCalendar 条目类型 */ item_type?: string | null; /** iCalendar LOCATION */ location?: string | null; /** iCalendar CATEGORIES */ categories?: string | null; /** iCalendar CLASS */ classification?: string | null; /** 截止时间(旧字段) */ deadline?: string | null; /** 开始时间 */ start_time?: string | null; /** 结束时间 */ end_time?: string | null; /** iCalendar DTSTART */ dtstart?: string | null; /** iCalendar DTEND */ dtend?: string | null; /** iCalendar DUE */ due?: string | null; /** iCalendar DURATION */ duration?: string | null; /** 时区(IANA) */ time_zone?: string | null; /** iCalendar TZID */ tzid?: string | null; /** 是否全天 */ is_all_day?: boolean; /** iCalendar DTSTAMP */ dtstamp?: string | null; /** iCalendar CREATED */ created?: string | null; /** iCalendar LAST-MODIFIED */ last_modified?: string | null; /** iCalendar SEQUENCE */ sequence?: number | null; /** iCalendar RDATE */ rdate?: string | null; /** iCalendar EXDATE */ exdate?: string | null; /** iCalendar RECURRENCE-ID */ recurrence_id?: string | null; /** iCalendar RELATED-TO UID */ related_to_uid?: string | null; /** iCalendar RELATED-TO RELTYPE */ related_to_reltype?: string | null; /** iCalendar STATUS */ ical_status?: string | null; /** 提醒偏移列表(分钟,基于 dtstart/due) */ reminder_offsets?: number[] | null; /** 状态 */ status: string; /** 优先级 */ priority: string; /** 完成时间 */ completed_at?: string | null; /** 完成百分比(0-100) */ percent_complete?: number; /** iCalendar RRULE */ rrule?: string | null; /** 同级待办之间的展示排序 */ order?: number; /** 标签名称列表 */ tags?: string[]; /** 附件列表 */ attachments?: TodoAttachmentResponse[]; /** 关联活动ID列表 */ related_activities?: number[]; /** 创建时间 */ created_at: string; /** 更新时间 */ updated_at: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseCompletedAt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 完成时间 */ export type TodoResponseCompletedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseDeadline.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 截止时间 */ export type TodoResponseDeadline = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseDescription.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 描述 */ export type TodoResponseDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseEndTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 结束时间 */ export type TodoResponseEndTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseParentTodoId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 父级待办ID */ export type TodoResponseParentTodoId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseRrule.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * iCalendar RRULE */ export type TodoResponseRrule = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseStartTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 开始时间 */ export type TodoResponseStartTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoResponseUserNotes.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 用户笔记 */ export type TodoResponseUserNotes = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoStatus.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * Todo 状态枚举(与前端保持一致) */ export type TodoStatus = typeof TodoStatus[keyof typeof TodoStatus]; export const TodoStatus = { active: 'active', completed: 'completed', canceled: 'canceled', draft: 'draft', } as const; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoTimeInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoTimeInfoTimeType } from './todoTimeInfoTimeType'; /** * 待办时间信息结构 */ export interface TodoTimeInfo { /** 时间类型:relative(相对时间)或 absolute(绝对时间) */ time_type: TodoTimeInfoTimeType; /** 相对天数(0=今天,1=明天,2=后天,-1=昨天) */ relative_days?: number | null; /** 相对时间点,24小时制格式(如:'13:00', '15:30') */ relative_time?: string | null; /** 绝对时间(ISO 8601格式),仅在time_type为absolute时使用 */ absolute_time?: string | null; /** 原始时间文本,用于验证和调试 */ raw_text: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoTimeInfoAbsoluteTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 绝对时间(ISO 8601格式),仅在time_type为absolute时使用 */ export type TodoTimeInfoAbsoluteTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoTimeInfoRelativeDays.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 相对天数(0=今天,1=明天,2=后天,-1=昨天) */ export type TodoTimeInfoRelativeDays = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoTimeInfoRelativeTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 相对时间点,24小时制格式(如:'13:00', '15:30') */ export type TodoTimeInfoRelativeTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoTimeInfoTimeType.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 时间类型:relative(相对时间)或 absolute(绝对时间) */ export type TodoTimeInfoTimeType = typeof TodoTimeInfoTimeType[keyof typeof TodoTimeInfoTimeType]; export const TodoTimeInfoTimeType = { relative: 'relative', absolute: 'absolute', } as const; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdate.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { TodoItemType } from './todoItemType'; import type { TodoPriority } from './todoPriority'; import type { TodoStatus } from './todoStatus'; /** * 更新 Todo 请求模型(字段均可选) */ export interface TodoUpdate { /** 待办名称 */ name?: string | null; /** iCalendar SUMMARY */ summary?: string | null; /** 描述 */ description?: string | null; /** 用户笔记 */ user_notes?: string | null; /** 父级待办ID(显式传 null 可清空) */ parent_todo_id?: number | null; /** iCalendar 条目类型 */ item_type?: TodoItemType | null; /** iCalendar LOCATION */ location?: string | null; /** iCalendar CATEGORIES */ categories?: string | null; /** iCalendar CLASS */ classification?: string | null; /** 截止时间(旧字段,显式传 null 可清空) */ deadline?: string | null; /** 开始时间(显式传 null 可清空) */ start_time?: string | null; /** 结束时间(显式传 null 可清空) */ end_time?: string | null; /** iCalendar DTSTART(显式传 null 可清空) */ dtstart?: string | null; /** iCalendar DTEND(显式传 null 可清空) */ dtend?: string | null; /** iCalendar DUE(显式传 null 可清空) */ due?: string | null; /** iCalendar DURATION(显式传 null 可清空) */ duration?: string | null; /** 时区(显式传 null 可清空) */ time_zone?: string | null; /** iCalendar TZID(显式传 null 可清空) */ tzid?: string | null; /** 是否全天(显式传 null 可清空) */ is_all_day?: boolean | null; /** iCalendar DTSTAMP(显式传 null 可清空) */ dtstamp?: string | null; /** iCalendar CREATED(显式传 null 可清空) */ created?: string | null; /** iCalendar LAST-MODIFIED(显式传 null 可清空) */ last_modified?: string | null; /** iCalendar SEQUENCE(显式传 null 可清空) */ sequence?: number | null; /** iCalendar RDATE(显式传 null 可清空) */ rdate?: string | null; /** iCalendar EXDATE(显式传 null 可清空) */ exdate?: string | null; /** iCalendar RECURRENCE-ID(显式传 null 可清空) */ recurrence_id?: string | null; /** iCalendar RELATED-TO UID(显式传 null 可清空) */ related_to_uid?: string | null; /** iCalendar RELATED-TO RELTYPE(显式传 null 可清空) */ related_to_reltype?: string | null; /** iCalendar STATUS(显式传 null 可清空) */ ical_status?: string | null; /** 提醒偏移列表(分钟,显式传 null 可回退默认) */ reminder_offsets?: number[] | null; /** 状态 */ status?: TodoStatus | null; /** 优先级 */ priority?: TodoPriority | null; /** 完成时间(显式传 null 可清空) */ completed_at?: string | null; /** 完成百分比(0-100) */ percent_complete?: number | null; /** iCalendar RRULE(显式传 null 可清空) */ rrule?: string | null; /** 同级待办之间的展示排序 */ order?: number | null; /** 标签名称列表(显式传空数组将清空) */ tags?: string[] | null; /** 关联活动ID列表(显式传空数组将清空) */ related_activities?: number[] | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateCompletedAt.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 完成时间(显式传 null 可清空) */ export type TodoUpdateCompletedAt = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateDeadline.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 截止时间(显式传 null 可清空) */ export type TodoUpdateDeadline = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateDescription.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 描述 */ export type TodoUpdateDescription = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateEndTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 结束时间(显式传 null 可清空) */ export type TodoUpdateEndTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 待办名称 */ export type TodoUpdateName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateOrder.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 同级待办之间的展示排序 */ export type TodoUpdateOrder = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateParentTodoId.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 父级待办ID(显式传 null 可清空) */ export type TodoUpdateParentTodoId = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdatePercentComplete.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 完成百分比(0-100) */ export type TodoUpdatePercentComplete = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdatePriority.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TodoPriority } from './todoPriority'; /** * 优先级 */ export type TodoUpdatePriority = TodoPriority | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateRelatedActivities.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 关联活动ID列表(显式传空数组将清空) */ export type TodoUpdateRelatedActivities = number[] | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateRrule.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * iCalendar RRULE(显式传 null 可清空) */ export type TodoUpdateRrule = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateStartTime.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 开始时间(显式传 null 可清空) */ export type TodoUpdateStartTime = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateStatus.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { TodoStatus } from './todoStatus'; /** * 状态 */ export type TodoUpdateStatus = TodoStatus | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateTags.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 标签名称列表(显式传空数组将清空) */ export type TodoUpdateTags = string[] | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/todoUpdateUserNotes.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 用户笔记 */ export type TodoUpdateUserNotes = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/updateJournalApiJournalsJournalIdPutBody.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ import type { JournalUpdate } from './journalUpdate'; export type UpdateJournalApiJournalsJournalIdPutBody = JournalUpdate | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/validationError.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface ValidationError { loc: (string | number)[]; msg: string; type: string; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/validationErrorLocItem.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type ValidationErrorLocItem = string | number; ================================================ FILE: free-todo-frontend/lib/generated/schemas/vectorStatsResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ export interface VectorStatsResponse { enabled: boolean; collection_name?: string | null; document_count?: number | null; error?: string | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/vectorStatsResponseCollectionName.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type VectorStatsResponseCollectionName = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/vectorStatsResponseDocumentCount.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type VectorStatsResponseDocumentCount = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/vectorStatsResponseError.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type VectorStatsResponseError = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatRequest.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * 视觉多模态聊天请求模型 */ export interface VisionChatRequest { /** 截图ID列表,至少包含一个截图ID */ screenshot_ids: number[]; /** * 文本提示词 * @minLength 1 */ prompt: string; /** 视觉模型名称,如果不提供则使用配置中的默认模型 */ model?: string | null; /** 温度参数,控制输出的随机性 */ temperature?: number | null; /** 最大生成token数 */ max_tokens?: number | null; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatRequestMaxTokens.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 最大生成token数 */ export type VisionChatRequestMaxTokens = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatRequestModel.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 视觉模型名称,如果不提供则使用配置中的默认模型 */ export type VisionChatRequestModel = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatRequestTemperature.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 温度参数,控制输出的随机性 */ export type VisionChatRequestTemperature = number | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatResponse.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import type { VisionChatResponseUsageInfo } from './visionChatResponseUsageInfo'; /** * 视觉多模态聊天响应模型 */ export interface VisionChatResponse { /** 模型生成的响应文本 */ response: string; /** 响应时间戳 */ timestamp?: string; /** Token使用信息 */ usage_info?: VisionChatResponseUsageInfo; /** 实际使用的模型名称 */ model?: string | null; /** 实际处理的截图数量 */ screenshot_count: number; } ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatResponseModel.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ /** * 实际使用的模型名称 */ export type VisionChatResponseModel = string | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatResponseUsageInfo.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ /** * Token使用信息 */ export type VisionChatResponseUsageInfo = { [key: string]: unknown } | null; ================================================ FILE: free-todo-frontend/lib/generated/schemas/visionChatResponseUsageInfoAnyOf.ts ================================================ /** * Generated by orval v7.17.2 🍺 * Do not edit manually. * LifeTrace API * 智能生活记录系统 API * OpenAPI spec version: 0.1.0 */ export type VisionChatResponseUsageInfoAnyOf = { [key: string]: unknown }; ================================================ FILE: free-todo-frontend/lib/generated/screenshot/screenshot.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { GetScreenshotsApiScreenshotsGetParams, HTTPValidationError, ScreenshotResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取截图列表 * @summary Get Screenshots */ export type getScreenshotsApiScreenshotsGetResponse200 = { data: ScreenshotResponse[] status: 200 } export type getScreenshotsApiScreenshotsGetResponse422 = { data: HTTPValidationError status: 422 } export type getScreenshotsApiScreenshotsGetResponseSuccess = (getScreenshotsApiScreenshotsGetResponse200) & { headers: Headers; }; export type getScreenshotsApiScreenshotsGetResponseError = (getScreenshotsApiScreenshotsGetResponse422) & { headers: Headers; }; export type getScreenshotsApiScreenshotsGetResponse = (getScreenshotsApiScreenshotsGetResponseSuccess | getScreenshotsApiScreenshotsGetResponseError) export const getGetScreenshotsApiScreenshotsGetUrl = (params?: GetScreenshotsApiScreenshotsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/screenshots?${stringifiedParams}` : `/api/screenshots` } export const getScreenshotsApiScreenshotsGet = async (params?: GetScreenshotsApiScreenshotsGetParams, options?: RequestInit): Promise => { return customFetcher(getGetScreenshotsApiScreenshotsGetUrl(params), { ...options, method: 'GET' } );} export const getGetScreenshotsApiScreenshotsGetQueryKey = (params?: GetScreenshotsApiScreenshotsGetParams,) => { return [ `/api/screenshots`, ...(params ? [params] : []) ] as const; } export const getGetScreenshotsApiScreenshotsGetQueryOptions = >, TError = HTTPValidationError>(params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetScreenshotsApiScreenshotsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getScreenshotsApiScreenshotsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetScreenshotsApiScreenshotsGetQueryResult = NonNullable>> export type GetScreenshotsApiScreenshotsGetQueryError = HTTPValidationError export function useGetScreenshotsApiScreenshotsGet>, TError = HTTPValidationError>( params: undefined | GetScreenshotsApiScreenshotsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetScreenshotsApiScreenshotsGet>, TError = HTTPValidationError>( params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetScreenshotsApiScreenshotsGet>, TError = HTTPValidationError>( params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Screenshots */ export function useGetScreenshotsApiScreenshotsGet>, TError = HTTPValidationError>( params?: GetScreenshotsApiScreenshotsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetScreenshotsApiScreenshotsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取单个截图详情 * @summary Get Screenshot */ export type getScreenshotApiScreenshotsScreenshotIdGetResponse200 = { data: unknown status: 200 } export type getScreenshotApiScreenshotsScreenshotIdGetResponse422 = { data: HTTPValidationError status: 422 } export type getScreenshotApiScreenshotsScreenshotIdGetResponseSuccess = (getScreenshotApiScreenshotsScreenshotIdGetResponse200) & { headers: Headers; }; export type getScreenshotApiScreenshotsScreenshotIdGetResponseError = (getScreenshotApiScreenshotsScreenshotIdGetResponse422) & { headers: Headers; }; export type getScreenshotApiScreenshotsScreenshotIdGetResponse = (getScreenshotApiScreenshotsScreenshotIdGetResponseSuccess | getScreenshotApiScreenshotsScreenshotIdGetResponseError) export const getGetScreenshotApiScreenshotsScreenshotIdGetUrl = (screenshotId: number,) => { return `/api/screenshots/${screenshotId}` } export const getScreenshotApiScreenshotsScreenshotIdGet = async (screenshotId: number, options?: RequestInit): Promise => { return customFetcher(getGetScreenshotApiScreenshotsScreenshotIdGetUrl(screenshotId), { ...options, method: 'GET' } );} export const getGetScreenshotApiScreenshotsScreenshotIdGetQueryKey = (screenshotId: number,) => { return [ `/api/screenshots/${screenshotId}` ] as const; } export const getGetScreenshotApiScreenshotsScreenshotIdGetQueryOptions = >, TError = HTTPValidationError>(screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetScreenshotApiScreenshotsScreenshotIdGetQueryKey(screenshotId); const queryFn: QueryFunction>> = ({ signal }) => getScreenshotApiScreenshotsScreenshotIdGet(screenshotId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(screenshotId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetScreenshotApiScreenshotsScreenshotIdGetQueryResult = NonNullable>> export type GetScreenshotApiScreenshotsScreenshotIdGetQueryError = HTTPValidationError export function useGetScreenshotApiScreenshotsScreenshotIdGet>, TError = HTTPValidationError>( screenshotId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetScreenshotApiScreenshotsScreenshotIdGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetScreenshotApiScreenshotsScreenshotIdGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Screenshot */ export function useGetScreenshotApiScreenshotsScreenshotIdGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetScreenshotApiScreenshotsScreenshotIdGetQueryOptions(screenshotId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取截图图片文件 * @summary Get Screenshot Image */ export type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse200 = { data: unknown status: 200 } export type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse422 = { data: HTTPValidationError status: 422 } export type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseSuccess = (getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse200) & { headers: Headers; }; export type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseError = (getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse422) & { headers: Headers; }; export type getScreenshotImageApiScreenshotsScreenshotIdImageGetResponse = (getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseSuccess | getScreenshotImageApiScreenshotsScreenshotIdImageGetResponseError) export const getGetScreenshotImageApiScreenshotsScreenshotIdImageGetUrl = (screenshotId: number,) => { return `/api/screenshots/${screenshotId}/image` } export const getScreenshotImageApiScreenshotsScreenshotIdImageGet = async (screenshotId: number, options?: RequestInit): Promise => { return customFetcher(getGetScreenshotImageApiScreenshotsScreenshotIdImageGetUrl(screenshotId), { ...options, method: 'GET' } );} export const getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryKey = (screenshotId: number,) => { return [ `/api/screenshots/${screenshotId}/image` ] as const; } export const getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryOptions = >, TError = HTTPValidationError>(screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryKey(screenshotId); const queryFn: QueryFunction>> = ({ signal }) => getScreenshotImageApiScreenshotsScreenshotIdImageGet(screenshotId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(screenshotId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryResult = NonNullable>> export type GetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryError = HTTPValidationError export function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet>, TError = HTTPValidationError>( screenshotId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Screenshot Image */ export function useGetScreenshotImageApiScreenshotsScreenshotIdImageGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetScreenshotImageApiScreenshotsScreenshotIdImageGetQueryOptions(screenshotId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取截图文件路径 * @summary Get Screenshot Path */ export type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse200 = { data: unknown status: 200 } export type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse422 = { data: HTTPValidationError status: 422 } export type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseSuccess = (getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse200) & { headers: Headers; }; export type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseError = (getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse422) & { headers: Headers; }; export type getScreenshotPathApiScreenshotsScreenshotIdPathGetResponse = (getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseSuccess | getScreenshotPathApiScreenshotsScreenshotIdPathGetResponseError) export const getGetScreenshotPathApiScreenshotsScreenshotIdPathGetUrl = (screenshotId: number,) => { return `/api/screenshots/${screenshotId}/path` } export const getScreenshotPathApiScreenshotsScreenshotIdPathGet = async (screenshotId: number, options?: RequestInit): Promise => { return customFetcher(getGetScreenshotPathApiScreenshotsScreenshotIdPathGetUrl(screenshotId), { ...options, method: 'GET' } );} export const getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryKey = (screenshotId: number,) => { return [ `/api/screenshots/${screenshotId}/path` ] as const; } export const getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryOptions = >, TError = HTTPValidationError>(screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryKey(screenshotId); const queryFn: QueryFunction>> = ({ signal }) => getScreenshotPathApiScreenshotsScreenshotIdPathGet(screenshotId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(screenshotId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryResult = NonNullable>> export type GetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryError = HTTPValidationError export function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet>, TError = HTTPValidationError>( screenshotId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Screenshot Path */ export function useGetScreenshotPathApiScreenshotsScreenshotIdPathGet>, TError = HTTPValidationError>( screenshotId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetScreenshotPathApiScreenshotsScreenshotIdPathGetQueryOptions(screenshotId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/search/search.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation } from '@tanstack/react-query'; import type { MutationFunction, QueryClient, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import type { EventResponse, HTTPValidationError, ScreenshotResponse, SearchRequest } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 搜索截图 * @summary Search Screenshots */ export type searchScreenshotsApiSearchPostResponse200 = { data: ScreenshotResponse[] status: 200 } export type searchScreenshotsApiSearchPostResponse422 = { data: HTTPValidationError status: 422 } export type searchScreenshotsApiSearchPostResponseSuccess = (searchScreenshotsApiSearchPostResponse200) & { headers: Headers; }; export type searchScreenshotsApiSearchPostResponseError = (searchScreenshotsApiSearchPostResponse422) & { headers: Headers; }; export type searchScreenshotsApiSearchPostResponse = (searchScreenshotsApiSearchPostResponseSuccess | searchScreenshotsApiSearchPostResponseError) export const getSearchScreenshotsApiSearchPostUrl = () => { return `/api/search` } export const searchScreenshotsApiSearchPost = async (searchRequest: SearchRequest, options?: RequestInit): Promise => { return customFetcher(getSearchScreenshotsApiSearchPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( searchRequest,) } );} export const getSearchScreenshotsApiSearchPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: SearchRequest}, TContext> => { const mutationKey = ['searchScreenshotsApiSearchPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: SearchRequest}> = (props) => { const {data} = props ?? {}; return searchScreenshotsApiSearchPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type SearchScreenshotsApiSearchPostMutationResult = NonNullable>> export type SearchScreenshotsApiSearchPostMutationBody = SearchRequest export type SearchScreenshotsApiSearchPostMutationError = HTTPValidationError /** * @summary Search Screenshots */ export const useSearchScreenshotsApiSearchPost = (options?: { mutation?:UseMutationOptions>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: SearchRequest}, TContext > => { return useMutation(getSearchScreenshotsApiSearchPostMutationOptions(options), queryClient); } /** * 事件级简单文本搜索:按OCR分组后返回事件摘要 * @summary Search Events */ export type searchEventsApiEventSearchPostResponse200 = { data: EventResponse[] status: 200 } export type searchEventsApiEventSearchPostResponse422 = { data: HTTPValidationError status: 422 } export type searchEventsApiEventSearchPostResponseSuccess = (searchEventsApiEventSearchPostResponse200) & { headers: Headers; }; export type searchEventsApiEventSearchPostResponseError = (searchEventsApiEventSearchPostResponse422) & { headers: Headers; }; export type searchEventsApiEventSearchPostResponse = (searchEventsApiEventSearchPostResponseSuccess | searchEventsApiEventSearchPostResponseError) export const getSearchEventsApiEventSearchPostUrl = () => { return `/api/event-search` } export const searchEventsApiEventSearchPost = async (searchRequest: SearchRequest, options?: RequestInit): Promise => { return customFetcher(getSearchEventsApiEventSearchPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( searchRequest,) } );} export const getSearchEventsApiEventSearchPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: SearchRequest}, TContext> => { const mutationKey = ['searchEventsApiEventSearchPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: SearchRequest}> = (props) => { const {data} = props ?? {}; return searchEventsApiEventSearchPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type SearchEventsApiEventSearchPostMutationResult = NonNullable>> export type SearchEventsApiEventSearchPostMutationBody = SearchRequest export type SearchEventsApiEventSearchPostMutationError = HTTPValidationError /** * @summary Search Events */ export const useSearchEventsApiEventSearchPost = (options?: { mutation?:UseMutationOptions>, TError,{data: SearchRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: SearchRequest}, TContext > => { return useMutation(getSearchEventsApiEventSearchPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/system/system.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { CapabilitiesResponse, CleanupOldDataApiCleanupPostParams, HTTPValidationError, StatisticsResponse, SystemResourcesResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取系统统计信息 * @summary Get Statistics */ export type getStatisticsApiStatisticsGetResponse200 = { data: StatisticsResponse status: 200 } export type getStatisticsApiStatisticsGetResponseSuccess = (getStatisticsApiStatisticsGetResponse200) & { headers: Headers; }; ; export type getStatisticsApiStatisticsGetResponse = (getStatisticsApiStatisticsGetResponseSuccess) export const getGetStatisticsApiStatisticsGetUrl = () => { return `/api/statistics` } export const getStatisticsApiStatisticsGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetStatisticsApiStatisticsGetUrl(), { ...options, method: 'GET' } );} export const getGetStatisticsApiStatisticsGetQueryKey = () => { return [ `/api/statistics` ] as const; } export const getGetStatisticsApiStatisticsGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetStatisticsApiStatisticsGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getStatisticsApiStatisticsGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetStatisticsApiStatisticsGetQueryResult = NonNullable>> export type GetStatisticsApiStatisticsGetQueryError = unknown export function useGetStatisticsApiStatisticsGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetStatisticsApiStatisticsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetStatisticsApiStatisticsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Statistics */ export function useGetStatisticsApiStatisticsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetStatisticsApiStatisticsGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 清理旧数据 * @summary Cleanup Old Data */ export type cleanupOldDataApiCleanupPostResponse200 = { data: unknown status: 200 } export type cleanupOldDataApiCleanupPostResponse422 = { data: HTTPValidationError status: 422 } export type cleanupOldDataApiCleanupPostResponseSuccess = (cleanupOldDataApiCleanupPostResponse200) & { headers: Headers; }; export type cleanupOldDataApiCleanupPostResponseError = (cleanupOldDataApiCleanupPostResponse422) & { headers: Headers; }; export type cleanupOldDataApiCleanupPostResponse = (cleanupOldDataApiCleanupPostResponseSuccess | cleanupOldDataApiCleanupPostResponseError) export const getCleanupOldDataApiCleanupPostUrl = (params?: CleanupOldDataApiCleanupPostParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/cleanup?${stringifiedParams}` : `/api/cleanup` } export const cleanupOldDataApiCleanupPost = async (params?: CleanupOldDataApiCleanupPostParams, options?: RequestInit): Promise => { return customFetcher(getCleanupOldDataApiCleanupPostUrl(params), { ...options, method: 'POST' } );} export const getCleanupOldDataApiCleanupPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{params?: CleanupOldDataApiCleanupPostParams}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{params?: CleanupOldDataApiCleanupPostParams}, TContext> => { const mutationKey = ['cleanupOldDataApiCleanupPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {params?: CleanupOldDataApiCleanupPostParams}> = (props) => { const {params} = props ?? {}; return cleanupOldDataApiCleanupPost(params,requestOptions) } return { mutationFn, ...mutationOptions }} export type CleanupOldDataApiCleanupPostMutationResult = NonNullable>> export type CleanupOldDataApiCleanupPostMutationError = HTTPValidationError /** * @summary Cleanup Old Data */ export const useCleanupOldDataApiCleanupPost = (options?: { mutation?:UseMutationOptions>, TError,{params?: CleanupOldDataApiCleanupPostParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {params?: CleanupOldDataApiCleanupPostParams}, TContext > => { return useMutation(getCleanupOldDataApiCleanupPostMutationOptions(options), queryClient); } /** * 获取系统资源使用情况 * @summary Get System Resources */ export type getSystemResourcesApiSystemResourcesGetResponse200 = { data: SystemResourcesResponse status: 200 } export type getSystemResourcesApiSystemResourcesGetResponseSuccess = (getSystemResourcesApiSystemResourcesGetResponse200) & { headers: Headers; }; ; export type getSystemResourcesApiSystemResourcesGetResponse = (getSystemResourcesApiSystemResourcesGetResponseSuccess) export const getGetSystemResourcesApiSystemResourcesGetUrl = () => { return `/api/system-resources` } export const getSystemResourcesApiSystemResourcesGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetSystemResourcesApiSystemResourcesGetUrl(), { ...options, method: 'GET' } );} export const getGetSystemResourcesApiSystemResourcesGetQueryKey = () => { return [ `/api/system-resources` ] as const; } export const getGetSystemResourcesApiSystemResourcesGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetSystemResourcesApiSystemResourcesGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getSystemResourcesApiSystemResourcesGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetSystemResourcesApiSystemResourcesGetQueryResult = NonNullable>> export type GetSystemResourcesApiSystemResourcesGetQueryError = unknown export function useGetSystemResourcesApiSystemResourcesGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetSystemResourcesApiSystemResourcesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetSystemResourcesApiSystemResourcesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get System Resources */ export function useGetSystemResourcesApiSystemResourcesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetSystemResourcesApiSystemResourcesGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 获取后端模块能力状态 * @summary Get Capabilities */ export type getCapabilitiesApiCapabilitiesGetResponse200 = { data: CapabilitiesResponse status: 200 } export type getCapabilitiesApiCapabilitiesGetResponseSuccess = (getCapabilitiesApiCapabilitiesGetResponse200) & { headers: Headers; }; ; export type getCapabilitiesApiCapabilitiesGetResponse = (getCapabilitiesApiCapabilitiesGetResponseSuccess) export const getGetCapabilitiesApiCapabilitiesGetUrl = () => { return `/api/capabilities` } export const getCapabilitiesApiCapabilitiesGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetCapabilitiesApiCapabilitiesGetUrl(), { ...options, method: 'GET' } );} export const getGetCapabilitiesApiCapabilitiesGetQueryKey = () => { return [ `/api/capabilities` ] as const; } export const getGetCapabilitiesApiCapabilitiesGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetCapabilitiesApiCapabilitiesGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getCapabilitiesApiCapabilitiesGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetCapabilitiesApiCapabilitiesGetQueryResult = NonNullable>> export type GetCapabilitiesApiCapabilitiesGetQueryError = unknown export function useGetCapabilitiesApiCapabilitiesGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetCapabilitiesApiCapabilitiesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetCapabilitiesApiCapabilitiesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Capabilities */ export function useGetCapabilitiesApiCapabilitiesGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetCapabilitiesApiCapabilitiesGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/time-allocation/time-allocation.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { GetTimeAllocationApiTimeAllocationGetParams, HTTPValidationError, TimeAllocationResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取时间分配数据(支持日期区间或天数) * @summary Get Time Allocation */ export type getTimeAllocationApiTimeAllocationGetResponse200 = { data: TimeAllocationResponse status: 200 } export type getTimeAllocationApiTimeAllocationGetResponse422 = { data: HTTPValidationError status: 422 } export type getTimeAllocationApiTimeAllocationGetResponseSuccess = (getTimeAllocationApiTimeAllocationGetResponse200) & { headers: Headers; }; export type getTimeAllocationApiTimeAllocationGetResponseError = (getTimeAllocationApiTimeAllocationGetResponse422) & { headers: Headers; }; export type getTimeAllocationApiTimeAllocationGetResponse = (getTimeAllocationApiTimeAllocationGetResponseSuccess | getTimeAllocationApiTimeAllocationGetResponseError) export const getGetTimeAllocationApiTimeAllocationGetUrl = (params?: GetTimeAllocationApiTimeAllocationGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/time-allocation?${stringifiedParams}` : `/api/time-allocation` } export const getTimeAllocationApiTimeAllocationGet = async (params?: GetTimeAllocationApiTimeAllocationGetParams, options?: RequestInit): Promise => { return customFetcher(getGetTimeAllocationApiTimeAllocationGetUrl(params), { ...options, method: 'GET' } );} export const getGetTimeAllocationApiTimeAllocationGetQueryKey = (params?: GetTimeAllocationApiTimeAllocationGetParams,) => { return [ `/api/time-allocation`, ...(params ? [params] : []) ] as const; } export const getGetTimeAllocationApiTimeAllocationGetQueryOptions = >, TError = HTTPValidationError>(params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetTimeAllocationApiTimeAllocationGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => getTimeAllocationApiTimeAllocationGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetTimeAllocationApiTimeAllocationGetQueryResult = NonNullable>> export type GetTimeAllocationApiTimeAllocationGetQueryError = HTTPValidationError export function useGetTimeAllocationApiTimeAllocationGet>, TError = HTTPValidationError>( params: undefined | GetTimeAllocationApiTimeAllocationGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetTimeAllocationApiTimeAllocationGet>, TError = HTTPValidationError>( params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetTimeAllocationApiTimeAllocationGet>, TError = HTTPValidationError>( params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Time Allocation */ export function useGetTimeAllocationApiTimeAllocationGet>, TError = HTTPValidationError>( params?: GetTimeAllocationApiTimeAllocationGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetTimeAllocationApiTimeAllocationGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } ================================================ FILE: free-todo-frontend/lib/generated/todo-extraction/todo-extraction.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation } from '@tanstack/react-query'; import type { MutationFunction, QueryClient, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import type { HTTPValidationError, TodoExtractionRequest, TodoExtractionResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 从事件中提取待办事项 针对白名单应用(微信、飞书等)的事件,使用多模态大模型分析截图, 提取用户承诺的待办事项,特别是带时间信息的待办。 Args: request: 待办提取请求,包含事件ID和可选的截图采样比例 Returns: 待办提取响应,包含提取的待办列表和元信息 Raises: HTTPException: 当请求参数无效或提取失败时 * @summary Extract Todos From Event */ export type extractTodosFromEventApiTodoExtractionExtractPostResponse200 = { data: TodoExtractionResponse status: 200 } export type extractTodosFromEventApiTodoExtractionExtractPostResponse422 = { data: HTTPValidationError status: 422 } export type extractTodosFromEventApiTodoExtractionExtractPostResponseSuccess = (extractTodosFromEventApiTodoExtractionExtractPostResponse200) & { headers: Headers; }; export type extractTodosFromEventApiTodoExtractionExtractPostResponseError = (extractTodosFromEventApiTodoExtractionExtractPostResponse422) & { headers: Headers; }; export type extractTodosFromEventApiTodoExtractionExtractPostResponse = (extractTodosFromEventApiTodoExtractionExtractPostResponseSuccess | extractTodosFromEventApiTodoExtractionExtractPostResponseError) export const getExtractTodosFromEventApiTodoExtractionExtractPostUrl = () => { return `/api/todo-extraction/extract` } export const extractTodosFromEventApiTodoExtractionExtractPost = async (todoExtractionRequest: TodoExtractionRequest, options?: RequestInit): Promise => { return customFetcher(getExtractTodosFromEventApiTodoExtractionExtractPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( todoExtractionRequest,) } );} export const getExtractTodosFromEventApiTodoExtractionExtractPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: TodoExtractionRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: TodoExtractionRequest}, TContext> => { const mutationKey = ['extractTodosFromEventApiTodoExtractionExtractPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: TodoExtractionRequest}> = (props) => { const {data} = props ?? {}; return extractTodosFromEventApiTodoExtractionExtractPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ExtractTodosFromEventApiTodoExtractionExtractPostMutationResult = NonNullable>> export type ExtractTodosFromEventApiTodoExtractionExtractPostMutationBody = TodoExtractionRequest export type ExtractTodosFromEventApiTodoExtractionExtractPostMutationError = HTTPValidationError /** * @summary Extract Todos From Event */ export const useExtractTodosFromEventApiTodoExtractionExtractPost = (options?: { mutation?:UseMutationOptions>, TError,{data: TodoExtractionRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: TodoExtractionRequest}, TContext > => { return useMutation(getExtractTodosFromEventApiTodoExtractionExtractPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/todos/todos.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { BodyImportIcsApiTodosImportIcsPost, BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost, ExportIcsApiTodosExportIcsGetParams, HTTPValidationError, ListTodosApiTodosGetParams, TodoAttachmentResponse, TodoCreate, TodoListResponse, TodoReorderRequest, TodoResponse, TodoUpdate } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 获取待办列表 * @summary List Todos */ export type listTodosApiTodosGetResponse200 = { data: TodoListResponse status: 200 } export type listTodosApiTodosGetResponse422 = { data: HTTPValidationError status: 422 } export type listTodosApiTodosGetResponseSuccess = (listTodosApiTodosGetResponse200) & { headers: Headers; }; export type listTodosApiTodosGetResponseError = (listTodosApiTodosGetResponse422) & { headers: Headers; }; export type listTodosApiTodosGetResponse = (listTodosApiTodosGetResponseSuccess | listTodosApiTodosGetResponseError) export const getListTodosApiTodosGetUrl = (params?: ListTodosApiTodosGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/todos?${stringifiedParams}` : `/api/todos` } export const listTodosApiTodosGet = async (params?: ListTodosApiTodosGetParams, options?: RequestInit): Promise => { return customFetcher(getListTodosApiTodosGetUrl(params), { ...options, method: 'GET' } );} export const getListTodosApiTodosGetQueryKey = (params?: ListTodosApiTodosGetParams,) => { return [ `/api/todos`, ...(params ? [params] : []) ] as const; } export const getListTodosApiTodosGetQueryOptions = >, TError = HTTPValidationError>(params?: ListTodosApiTodosGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getListTodosApiTodosGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => listTodosApiTodosGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type ListTodosApiTodosGetQueryResult = NonNullable>> export type ListTodosApiTodosGetQueryError = HTTPValidationError export function useListTodosApiTodosGet>, TError = HTTPValidationError>( params: undefined | ListTodosApiTodosGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useListTodosApiTodosGet>, TError = HTTPValidationError>( params?: ListTodosApiTodosGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useListTodosApiTodosGet>, TError = HTTPValidationError>( params?: ListTodosApiTodosGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary List Todos */ export function useListTodosApiTodosGet>, TError = HTTPValidationError>( params?: ListTodosApiTodosGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getListTodosApiTodosGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 创建待办 * @summary Create Todo */ export type createTodoApiTodosPostResponse201 = { data: TodoResponse status: 201 } export type createTodoApiTodosPostResponse422 = { data: HTTPValidationError status: 422 } export type createTodoApiTodosPostResponseSuccess = (createTodoApiTodosPostResponse201) & { headers: Headers; }; export type createTodoApiTodosPostResponseError = (createTodoApiTodosPostResponse422) & { headers: Headers; }; export type createTodoApiTodosPostResponse = (createTodoApiTodosPostResponseSuccess | createTodoApiTodosPostResponseError) export const getCreateTodoApiTodosPostUrl = () => { return `/api/todos` } export const createTodoApiTodosPost = async (todoCreate: TodoCreate, options?: RequestInit): Promise => { return customFetcher(getCreateTodoApiTodosPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( todoCreate,) } );} export const getCreateTodoApiTodosPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: TodoCreate}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: TodoCreate}, TContext> => { const mutationKey = ['createTodoApiTodosPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: TodoCreate}> = (props) => { const {data} = props ?? {}; return createTodoApiTodosPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type CreateTodoApiTodosPostMutationResult = NonNullable>> export type CreateTodoApiTodosPostMutationBody = TodoCreate export type CreateTodoApiTodosPostMutationError = HTTPValidationError /** * @summary Create Todo */ export const useCreateTodoApiTodosPost = (options?: { mutation?:UseMutationOptions>, TError,{data: TodoCreate}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: TodoCreate}, TContext > => { return useMutation(getCreateTodoApiTodosPostMutationOptions(options), queryClient); } /** * 获取单个待办 * @summary Get Todo */ export type getTodoApiTodosTodoIdGetResponse200 = { data: TodoResponse status: 200 } export type getTodoApiTodosTodoIdGetResponse422 = { data: HTTPValidationError status: 422 } export type getTodoApiTodosTodoIdGetResponseSuccess = (getTodoApiTodosTodoIdGetResponse200) & { headers: Headers; }; export type getTodoApiTodosTodoIdGetResponseError = (getTodoApiTodosTodoIdGetResponse422) & { headers: Headers; }; export type getTodoApiTodosTodoIdGetResponse = (getTodoApiTodosTodoIdGetResponseSuccess | getTodoApiTodosTodoIdGetResponseError) export const getGetTodoApiTodosTodoIdGetUrl = (todoId: number,) => { return `/api/todos/${todoId}` } export const getTodoApiTodosTodoIdGet = async (todoId: number, options?: RequestInit): Promise => { return customFetcher(getGetTodoApiTodosTodoIdGetUrl(todoId), { ...options, method: 'GET' } );} export const getGetTodoApiTodosTodoIdGetQueryKey = (todoId: number,) => { return [ `/api/todos/${todoId}` ] as const; } export const getGetTodoApiTodosTodoIdGetQueryOptions = >, TError = HTTPValidationError>(todoId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetTodoApiTodosTodoIdGetQueryKey(todoId); const queryFn: QueryFunction>> = ({ signal }) => getTodoApiTodosTodoIdGet(todoId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(todoId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetTodoApiTodosTodoIdGetQueryResult = NonNullable>> export type GetTodoApiTodosTodoIdGetQueryError = HTTPValidationError export function useGetTodoApiTodosTodoIdGet>, TError = HTTPValidationError>( todoId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetTodoApiTodosTodoIdGet>, TError = HTTPValidationError>( todoId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetTodoApiTodosTodoIdGet>, TError = HTTPValidationError>( todoId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Todo */ export function useGetTodoApiTodosTodoIdGet>, TError = HTTPValidationError>( todoId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetTodoApiTodosTodoIdGetQueryOptions(todoId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 更新待办 * @summary Update Todo */ export type updateTodoApiTodosTodoIdPutResponse200 = { data: TodoResponse status: 200 } export type updateTodoApiTodosTodoIdPutResponse422 = { data: HTTPValidationError status: 422 } export type updateTodoApiTodosTodoIdPutResponseSuccess = (updateTodoApiTodosTodoIdPutResponse200) & { headers: Headers; }; export type updateTodoApiTodosTodoIdPutResponseError = (updateTodoApiTodosTodoIdPutResponse422) & { headers: Headers; }; export type updateTodoApiTodosTodoIdPutResponse = (updateTodoApiTodosTodoIdPutResponseSuccess | updateTodoApiTodosTodoIdPutResponseError) export const getUpdateTodoApiTodosTodoIdPutUrl = (todoId: number,) => { return `/api/todos/${todoId}` } export const updateTodoApiTodosTodoIdPut = async (todoId: number, todoUpdateNull: TodoUpdate | null, options?: RequestInit): Promise => { return customFetcher(getUpdateTodoApiTodosTodoIdPutUrl(todoId), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( todoUpdateNull,) } );} export const getUpdateTodoApiTodosTodoIdPutMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number;data: TodoUpdate | null}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{todoId: number;data: TodoUpdate | null}, TContext> => { const mutationKey = ['updateTodoApiTodosTodoIdPut']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {todoId: number;data: TodoUpdate | null}> = (props) => { const {todoId,data} = props ?? {}; return updateTodoApiTodosTodoIdPut(todoId,data,requestOptions) } return { mutationFn, ...mutationOptions }} export type UpdateTodoApiTodosTodoIdPutMutationResult = NonNullable>> export type UpdateTodoApiTodosTodoIdPutMutationBody = TodoUpdate | null export type UpdateTodoApiTodosTodoIdPutMutationError = HTTPValidationError /** * @summary Update Todo */ export const useUpdateTodoApiTodosTodoIdPut = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number;data: TodoUpdate | null}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {todoId: number;data: TodoUpdate | null}, TContext > => { return useMutation(getUpdateTodoApiTodosTodoIdPutMutationOptions(options), queryClient); } /** * 删除待办 * @summary Delete Todo */ export type deleteTodoApiTodosTodoIdDeleteResponse204 = { data: void status: 204 } export type deleteTodoApiTodosTodoIdDeleteResponse422 = { data: HTTPValidationError status: 422 } export type deleteTodoApiTodosTodoIdDeleteResponseSuccess = (deleteTodoApiTodosTodoIdDeleteResponse204) & { headers: Headers; }; export type deleteTodoApiTodosTodoIdDeleteResponseError = (deleteTodoApiTodosTodoIdDeleteResponse422) & { headers: Headers; }; export type deleteTodoApiTodosTodoIdDeleteResponse = (deleteTodoApiTodosTodoIdDeleteResponseSuccess | deleteTodoApiTodosTodoIdDeleteResponseError) export const getDeleteTodoApiTodosTodoIdDeleteUrl = (todoId: number,) => { return `/api/todos/${todoId}` } export const deleteTodoApiTodosTodoIdDelete = async (todoId: number, options?: RequestInit): Promise => { return customFetcher(getDeleteTodoApiTodosTodoIdDeleteUrl(todoId), { ...options, method: 'DELETE' } );} export const getDeleteTodoApiTodosTodoIdDeleteMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{todoId: number}, TContext> => { const mutationKey = ['deleteTodoApiTodosTodoIdDelete']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {todoId: number}> = (props) => { const {todoId} = props ?? {}; return deleteTodoApiTodosTodoIdDelete(todoId,requestOptions) } return { mutationFn, ...mutationOptions }} export type DeleteTodoApiTodosTodoIdDeleteMutationResult = NonNullable>> export type DeleteTodoApiTodosTodoIdDeleteMutationError = HTTPValidationError /** * @summary Delete Todo */ export const useDeleteTodoApiTodosTodoIdDelete = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {todoId: number}, TContext > => { return useMutation(getDeleteTodoApiTodosTodoIdDeleteMutationOptions(options), queryClient); } /** * 上传附件并绑定到 Todo * @summary Upload Attachments */ export type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse201 = { data: TodoAttachmentResponse[] status: 201 } export type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse422 = { data: HTTPValidationError status: 422 } export type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseSuccess = (uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse201) & { headers: Headers; }; export type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseError = (uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse422) & { headers: Headers; }; export type uploadAttachmentsApiTodosTodoIdAttachmentsPostResponse = (uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseSuccess | uploadAttachmentsApiTodosTodoIdAttachmentsPostResponseError) export const getUploadAttachmentsApiTodosTodoIdAttachmentsPostUrl = (todoId: number,) => { return `/api/todos/${todoId}/attachments` } export const uploadAttachmentsApiTodosTodoIdAttachmentsPost = async (todoId: number, bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost, options?: RequestInit): Promise => { const formData = new FormData(); bodyUploadAttachmentsApiTodosTodoIdAttachmentsPost.files.forEach(value => formData.append(`files`, value)); return customFetcher(getUploadAttachmentsApiTodosTodoIdAttachmentsPostUrl(todoId), { ...options, method: 'POST' , body: formData, } );} export const getUploadAttachmentsApiTodosTodoIdAttachmentsPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}, TContext> => { const mutationKey = ['uploadAttachmentsApiTodosTodoIdAttachmentsPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}> = (props) => { const {todoId,data} = props ?? {}; return uploadAttachmentsApiTodosTodoIdAttachmentsPost(todoId,data,requestOptions) } return { mutationFn, ...mutationOptions }} export type UploadAttachmentsApiTodosTodoIdAttachmentsPostMutationResult = NonNullable>> export type UploadAttachmentsApiTodosTodoIdAttachmentsPostMutationBody = BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost export type UploadAttachmentsApiTodosTodoIdAttachmentsPostMutationError = HTTPValidationError /** * @summary Upload Attachments */ export const useUploadAttachmentsApiTodosTodoIdAttachmentsPost = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {todoId: number;data: BodyUploadAttachmentsApiTodosTodoIdAttachmentsPost}, TContext > => { return useMutation(getUploadAttachmentsApiTodosTodoIdAttachmentsPostMutationOptions(options), queryClient); } /** * 解绑附件(不删除实际文件) * @summary Delete Attachment */ export type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse204 = { data: void status: 204 } export type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse422 = { data: HTTPValidationError status: 422 } export type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseSuccess = (deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse204) & { headers: Headers; }; export type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseError = (deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse422) & { headers: Headers; }; export type deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponse = (deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseSuccess | deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteResponseError) export const getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteUrl = (todoId: number, attachmentId: number,) => { return `/api/todos/${todoId}/attachments/${attachmentId}` } export const deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete = async (todoId: number, attachmentId: number, options?: RequestInit): Promise => { return customFetcher(getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteUrl(todoId,attachmentId), { ...options, method: 'DELETE' } );} export const getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number;attachmentId: number}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{todoId: number;attachmentId: number}, TContext> => { const mutationKey = ['deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {todoId: number;attachmentId: number}> = (props) => { const {todoId,attachmentId} = props ?? {}; return deleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete(todoId,attachmentId,requestOptions) } return { mutationFn, ...mutationOptions }} export type DeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationResult = NonNullable>> export type DeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationError = HTTPValidationError /** * @summary Delete Attachment */ export const useDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDelete = (options?: { mutation?:UseMutationOptions>, TError,{todoId: number;attachmentId: number}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {todoId: number;attachmentId: number}, TContext > => { return useMutation(getDeleteAttachmentApiTodosTodoIdAttachmentsAttachmentIdDeleteMutationOptions(options), queryClient); } /** * 下载附件文件 * @summary Get Attachment File */ export type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse200 = { data: unknown status: 200 } export type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse422 = { data: HTTPValidationError status: 422 } export type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseSuccess = (getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse200) & { headers: Headers; }; export type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseError = (getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse422) & { headers: Headers; }; export type getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponse = (getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseSuccess | getAttachmentFileApiTodosAttachmentsAttachmentIdFileGetResponseError) export const getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetUrl = (attachmentId: number,) => { return `/api/todos/attachments/${attachmentId}/file` } export const getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet = async (attachmentId: number, options?: RequestInit): Promise => { return customFetcher(getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetUrl(attachmentId), { ...options, method: 'GET' } );} export const getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryKey = (attachmentId: number,) => { return [ `/api/todos/attachments/${attachmentId}/file` ] as const; } export const getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryOptions = >, TError = HTTPValidationError>(attachmentId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryKey(attachmentId); const queryFn: QueryFunction>> = ({ signal }) => getAttachmentFileApiTodosAttachmentsAttachmentIdFileGet(attachmentId, { signal, ...requestOptions }); return { queryKey, queryFn, enabled: !!(attachmentId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryResult = NonNullable>> export type GetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryError = HTTPValidationError export function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>, TError = HTTPValidationError>( attachmentId: number, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>, TError = HTTPValidationError>( attachmentId: number, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>, TError = HTTPValidationError>( attachmentId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Attachment File */ export function useGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGet>, TError = HTTPValidationError>( attachmentId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetAttachmentFileApiTodosAttachmentsAttachmentIdFileGetQueryOptions(attachmentId,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 批量更新待办的排序和父子关系 * @summary Reorder Todos */ export type reorderTodosApiTodosReorderPostResponse200 = { data: unknown status: 200 } export type reorderTodosApiTodosReorderPostResponse422 = { data: HTTPValidationError status: 422 } export type reorderTodosApiTodosReorderPostResponseSuccess = (reorderTodosApiTodosReorderPostResponse200) & { headers: Headers; }; export type reorderTodosApiTodosReorderPostResponseError = (reorderTodosApiTodosReorderPostResponse422) & { headers: Headers; }; export type reorderTodosApiTodosReorderPostResponse = (reorderTodosApiTodosReorderPostResponseSuccess | reorderTodosApiTodosReorderPostResponseError) export const getReorderTodosApiTodosReorderPostUrl = () => { return `/api/todos/reorder` } export const reorderTodosApiTodosReorderPost = async (todoReorderRequest: TodoReorderRequest, options?: RequestInit): Promise => { return customFetcher(getReorderTodosApiTodosReorderPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( todoReorderRequest,) } );} export const getReorderTodosApiTodosReorderPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: TodoReorderRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: TodoReorderRequest}, TContext> => { const mutationKey = ['reorderTodosApiTodosReorderPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: TodoReorderRequest}> = (props) => { const {data} = props ?? {}; return reorderTodosApiTodosReorderPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ReorderTodosApiTodosReorderPostMutationResult = NonNullable>> export type ReorderTodosApiTodosReorderPostMutationBody = TodoReorderRequest export type ReorderTodosApiTodosReorderPostMutationError = HTTPValidationError /** * @summary Reorder Todos */ export const useReorderTodosApiTodosReorderPost = (options?: { mutation?:UseMutationOptions>, TError,{data: TodoReorderRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: TodoReorderRequest}, TContext > => { return useMutation(getReorderTodosApiTodosReorderPostMutationOptions(options), queryClient); } /** * 导出 Todo 为 ICS 文件 * @summary Export Ics */ export type exportIcsApiTodosExportIcsGetResponse200 = { data: unknown status: 200 } export type exportIcsApiTodosExportIcsGetResponse422 = { data: HTTPValidationError status: 422 } export type exportIcsApiTodosExportIcsGetResponseSuccess = (exportIcsApiTodosExportIcsGetResponse200) & { headers: Headers; }; export type exportIcsApiTodosExportIcsGetResponseError = (exportIcsApiTodosExportIcsGetResponse422) & { headers: Headers; }; export type exportIcsApiTodosExportIcsGetResponse = (exportIcsApiTodosExportIcsGetResponseSuccess | exportIcsApiTodosExportIcsGetResponseError) export const getExportIcsApiTodosExportIcsGetUrl = (params?: ExportIcsApiTodosExportIcsGetParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/todos/export/ics?${stringifiedParams}` : `/api/todos/export/ics` } export const exportIcsApiTodosExportIcsGet = async (params?: ExportIcsApiTodosExportIcsGetParams, options?: RequestInit): Promise => { return customFetcher(getExportIcsApiTodosExportIcsGetUrl(params), { ...options, method: 'GET' } );} export const getExportIcsApiTodosExportIcsGetQueryKey = (params?: ExportIcsApiTodosExportIcsGetParams,) => { return [ `/api/todos/export/ics`, ...(params ? [params] : []) ] as const; } export const getExportIcsApiTodosExportIcsGetQueryOptions = >, TError = HTTPValidationError>(params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getExportIcsApiTodosExportIcsGetQueryKey(params); const queryFn: QueryFunction>> = ({ signal }) => exportIcsApiTodosExportIcsGet(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type ExportIcsApiTodosExportIcsGetQueryResult = NonNullable>> export type ExportIcsApiTodosExportIcsGetQueryError = HTTPValidationError export function useExportIcsApiTodosExportIcsGet>, TError = HTTPValidationError>( params: undefined | ExportIcsApiTodosExportIcsGetParams, options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useExportIcsApiTodosExportIcsGet>, TError = HTTPValidationError>( params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useExportIcsApiTodosExportIcsGet>, TError = HTTPValidationError>( params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Export Ics */ export function useExportIcsApiTodosExportIcsGet>, TError = HTTPValidationError>( params?: ExportIcsApiTodosExportIcsGetParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getExportIcsApiTodosExportIcsGetQueryOptions(params,options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 从 ICS 文件导入 Todo * @summary Import Ics */ export type importIcsApiTodosImportIcsPostResponse200 = { data: TodoResponse[] status: 200 } export type importIcsApiTodosImportIcsPostResponse422 = { data: HTTPValidationError status: 422 } export type importIcsApiTodosImportIcsPostResponseSuccess = (importIcsApiTodosImportIcsPostResponse200) & { headers: Headers; }; export type importIcsApiTodosImportIcsPostResponseError = (importIcsApiTodosImportIcsPostResponse422) & { headers: Headers; }; export type importIcsApiTodosImportIcsPostResponse = (importIcsApiTodosImportIcsPostResponseSuccess | importIcsApiTodosImportIcsPostResponseError) export const getImportIcsApiTodosImportIcsPostUrl = () => { return `/api/todos/import/ics` } export const importIcsApiTodosImportIcsPost = async (bodyImportIcsApiTodosImportIcsPost: BodyImportIcsApiTodosImportIcsPost, options?: RequestInit): Promise => { const formData = new FormData(); formData.append(`file`, bodyImportIcsApiTodosImportIcsPost.file); return customFetcher(getImportIcsApiTodosImportIcsPostUrl(), { ...options, method: 'POST' , body: formData, } );} export const getImportIcsApiTodosImportIcsPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: BodyImportIcsApiTodosImportIcsPost}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: BodyImportIcsApiTodosImportIcsPost}, TContext> => { const mutationKey = ['importIcsApiTodosImportIcsPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: BodyImportIcsApiTodosImportIcsPost}> = (props) => { const {data} = props ?? {}; return importIcsApiTodosImportIcsPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type ImportIcsApiTodosImportIcsPostMutationResult = NonNullable>> export type ImportIcsApiTodosImportIcsPostMutationBody = BodyImportIcsApiTodosImportIcsPost export type ImportIcsApiTodosImportIcsPostMutationError = HTTPValidationError /** * @summary Import Ics */ export const useImportIcsApiTodosImportIcsPost = (options?: { mutation?:UseMutationOptions>, TError,{data: BodyImportIcsApiTodosImportIcsPost}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: BodyImportIcsApiTodosImportIcsPost}, TContext > => { return useMutation(getImportIcsApiTodosImportIcsPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/vector/vector.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation, useQuery } from '@tanstack/react-query'; import type { DataTag, DefinedInitialDataOptions, DefinedUseQueryResult, MutationFunction, QueryClient, QueryFunction, QueryKey, UndefinedInitialDataOptions, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; import type { EventResponse, HTTPValidationError, SemanticSearchRequest, SemanticSearchResult, SyncVectorDatabaseApiVectorSyncPostParams, VectorStatsResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 语义搜索 OCR 结果 * @summary Semantic Search */ export type semanticSearchApiSemanticSearchPostResponse200 = { data: SemanticSearchResult[] status: 200 } export type semanticSearchApiSemanticSearchPostResponse422 = { data: HTTPValidationError status: 422 } export type semanticSearchApiSemanticSearchPostResponseSuccess = (semanticSearchApiSemanticSearchPostResponse200) & { headers: Headers; }; export type semanticSearchApiSemanticSearchPostResponseError = (semanticSearchApiSemanticSearchPostResponse422) & { headers: Headers; }; export type semanticSearchApiSemanticSearchPostResponse = (semanticSearchApiSemanticSearchPostResponseSuccess | semanticSearchApiSemanticSearchPostResponseError) export const getSemanticSearchApiSemanticSearchPostUrl = () => { return `/api/semantic-search` } export const semanticSearchApiSemanticSearchPost = async (semanticSearchRequest: SemanticSearchRequest, options?: RequestInit): Promise => { return customFetcher(getSemanticSearchApiSemanticSearchPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( semanticSearchRequest,) } );} export const getSemanticSearchApiSemanticSearchPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: SemanticSearchRequest}, TContext> => { const mutationKey = ['semanticSearchApiSemanticSearchPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: SemanticSearchRequest}> = (props) => { const {data} = props ?? {}; return semanticSearchApiSemanticSearchPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type SemanticSearchApiSemanticSearchPostMutationResult = NonNullable>> export type SemanticSearchApiSemanticSearchPostMutationBody = SemanticSearchRequest export type SemanticSearchApiSemanticSearchPostMutationError = HTTPValidationError /** * @summary Semantic Search */ export const useSemanticSearchApiSemanticSearchPost = (options?: { mutation?:UseMutationOptions>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: SemanticSearchRequest}, TContext > => { return useMutation(getSemanticSearchApiSemanticSearchPostMutationOptions(options), queryClient); } /** * 事件级语义搜索(基于事件聚合文本) * @summary Event Semantic Search */ export type eventSemanticSearchApiEventSemanticSearchPostResponse200 = { data: EventResponse[] status: 200 } export type eventSemanticSearchApiEventSemanticSearchPostResponse422 = { data: HTTPValidationError status: 422 } export type eventSemanticSearchApiEventSemanticSearchPostResponseSuccess = (eventSemanticSearchApiEventSemanticSearchPostResponse200) & { headers: Headers; }; export type eventSemanticSearchApiEventSemanticSearchPostResponseError = (eventSemanticSearchApiEventSemanticSearchPostResponse422) & { headers: Headers; }; export type eventSemanticSearchApiEventSemanticSearchPostResponse = (eventSemanticSearchApiEventSemanticSearchPostResponseSuccess | eventSemanticSearchApiEventSemanticSearchPostResponseError) export const getEventSemanticSearchApiEventSemanticSearchPostUrl = () => { return `/api/event-semantic-search` } export const eventSemanticSearchApiEventSemanticSearchPost = async (semanticSearchRequest: SemanticSearchRequest, options?: RequestInit): Promise => { return customFetcher(getEventSemanticSearchApiEventSemanticSearchPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( semanticSearchRequest,) } );} export const getEventSemanticSearchApiEventSemanticSearchPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: SemanticSearchRequest}, TContext> => { const mutationKey = ['eventSemanticSearchApiEventSemanticSearchPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: SemanticSearchRequest}> = (props) => { const {data} = props ?? {}; return eventSemanticSearchApiEventSemanticSearchPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type EventSemanticSearchApiEventSemanticSearchPostMutationResult = NonNullable>> export type EventSemanticSearchApiEventSemanticSearchPostMutationBody = SemanticSearchRequest export type EventSemanticSearchApiEventSemanticSearchPostMutationError = HTTPValidationError /** * @summary Event Semantic Search */ export const useEventSemanticSearchApiEventSemanticSearchPost = (options?: { mutation?:UseMutationOptions>, TError,{data: SemanticSearchRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: SemanticSearchRequest}, TContext > => { return useMutation(getEventSemanticSearchApiEventSemanticSearchPostMutationOptions(options), queryClient); } /** * 获取向量数据库统计信息 * @summary Get Vector Stats */ export type getVectorStatsApiVectorStatsGetResponse200 = { data: VectorStatsResponse status: 200 } export type getVectorStatsApiVectorStatsGetResponseSuccess = (getVectorStatsApiVectorStatsGetResponse200) & { headers: Headers; }; ; export type getVectorStatsApiVectorStatsGetResponse = (getVectorStatsApiVectorStatsGetResponseSuccess) export const getGetVectorStatsApiVectorStatsGetUrl = () => { return `/api/vector-stats` } export const getVectorStatsApiVectorStatsGet = async ( options?: RequestInit): Promise => { return customFetcher(getGetVectorStatsApiVectorStatsGetUrl(), { ...options, method: 'GET' } );} export const getGetVectorStatsApiVectorStatsGetQueryKey = () => { return [ `/api/vector-stats` ] as const; } export const getGetVectorStatsApiVectorStatsGetQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} ) => { const {query: queryOptions, request: requestOptions} = options ?? {}; const queryKey = queryOptions?.queryKey ?? getGetVectorStatsApiVectorStatsGetQueryKey(); const queryFn: QueryFunction>> = ({ signal }) => getVectorStatsApiVectorStatsGet({ signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } } export type GetVectorStatsApiVectorStatsGetQueryResult = NonNullable>> export type GetVectorStatsApiVectorStatsGetQueryError = unknown export function useGetVectorStatsApiVectorStatsGet>, TError = unknown>( options: { query:Partial>, TError, TData>> & Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): DefinedUseQueryResult & { queryKey: DataTag } export function useGetVectorStatsApiVectorStatsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>> & Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> > , 'initialData' >, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } export function useGetVectorStatsApiVectorStatsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } /** * @summary Get Vector Stats */ export function useGetVectorStatsApiVectorStatsGet>, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} , queryClient?: QueryClient ): UseQueryResult & { queryKey: DataTag } { const queryOptions = getGetVectorStatsApiVectorStatsGetQueryOptions(options) const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; return { ...query, queryKey: queryOptions.queryKey }; } /** * 同步 SQLite 数据库到向量数据库 * @summary Sync Vector Database */ export type syncVectorDatabaseApiVectorSyncPostResponse200 = { data: unknown status: 200 } export type syncVectorDatabaseApiVectorSyncPostResponse422 = { data: HTTPValidationError status: 422 } export type syncVectorDatabaseApiVectorSyncPostResponseSuccess = (syncVectorDatabaseApiVectorSyncPostResponse200) & { headers: Headers; }; export type syncVectorDatabaseApiVectorSyncPostResponseError = (syncVectorDatabaseApiVectorSyncPostResponse422) & { headers: Headers; }; export type syncVectorDatabaseApiVectorSyncPostResponse = (syncVectorDatabaseApiVectorSyncPostResponseSuccess | syncVectorDatabaseApiVectorSyncPostResponseError) export const getSyncVectorDatabaseApiVectorSyncPostUrl = (params?: SyncVectorDatabaseApiVectorSyncPostParams,) => { const normalizedParams = new URLSearchParams(); Object.entries(params || {}).forEach(([key, value]) => { if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } }); const stringifiedParams = normalizedParams.toString(); return stringifiedParams.length > 0 ? `/api/vector-sync?${stringifiedParams}` : `/api/vector-sync` } export const syncVectorDatabaseApiVectorSyncPost = async (params?: SyncVectorDatabaseApiVectorSyncPostParams, options?: RequestInit): Promise => { return customFetcher(getSyncVectorDatabaseApiVectorSyncPostUrl(params), { ...options, method: 'POST' } );} export const getSyncVectorDatabaseApiVectorSyncPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{params?: SyncVectorDatabaseApiVectorSyncPostParams}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{params?: SyncVectorDatabaseApiVectorSyncPostParams}, TContext> => { const mutationKey = ['syncVectorDatabaseApiVectorSyncPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {params?: SyncVectorDatabaseApiVectorSyncPostParams}> = (props) => { const {params} = props ?? {}; return syncVectorDatabaseApiVectorSyncPost(params,requestOptions) } return { mutationFn, ...mutationOptions }} export type SyncVectorDatabaseApiVectorSyncPostMutationResult = NonNullable>> export type SyncVectorDatabaseApiVectorSyncPostMutationError = HTTPValidationError /** * @summary Sync Vector Database */ export const useSyncVectorDatabaseApiVectorSyncPost = (options?: { mutation?:UseMutationOptions>, TError,{params?: SyncVectorDatabaseApiVectorSyncPostParams}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {params?: SyncVectorDatabaseApiVectorSyncPostParams}, TContext > => { return useMutation(getSyncVectorDatabaseApiVectorSyncPostMutationOptions(options), queryClient); } /** * 重置向量数据库 * @summary Reset Vector Database */ export type resetVectorDatabaseApiVectorResetPostResponse200 = { data: unknown status: 200 } export type resetVectorDatabaseApiVectorResetPostResponseSuccess = (resetVectorDatabaseApiVectorResetPostResponse200) & { headers: Headers; }; ; export type resetVectorDatabaseApiVectorResetPostResponse = (resetVectorDatabaseApiVectorResetPostResponseSuccess) export const getResetVectorDatabaseApiVectorResetPostUrl = () => { return `/api/vector-reset` } export const resetVectorDatabaseApiVectorResetPost = async ( options?: RequestInit): Promise => { return customFetcher(getResetVectorDatabaseApiVectorResetPostUrl(), { ...options, method: 'POST' } );} export const getResetVectorDatabaseApiVectorResetPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,void, TContext> => { const mutationKey = ['resetVectorDatabaseApiVectorResetPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, void> = () => { return resetVectorDatabaseApiVectorResetPost(requestOptions) } return { mutationFn, ...mutationOptions }} export type ResetVectorDatabaseApiVectorResetPostMutationResult = NonNullable>> export type ResetVectorDatabaseApiVectorResetPostMutationError = unknown /** * @summary Reset Vector Database */ export const useResetVectorDatabaseApiVectorResetPost = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, void, TContext > => { return useMutation(getResetVectorDatabaseApiVectorResetPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/generated/vision/vision.ts ================================================ /** * Generated by orval v8.2.0 🍺 * Do not edit manually. * FreeTodo API * FreeTodo API (part of FreeU Project) * OpenAPI spec version: 0.1.2 */ import { useMutation } from '@tanstack/react-query'; import type { MutationFunction, QueryClient, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; import type { HTTPValidationError, VisionChatRequest, VisionChatResponse } from '.././schemas'; import { customFetcher } from '../../api/fetcher'; type SecondParameter unknown> = Parameters[1]; /** * 视觉多模态聊天接口 使用通义千问视觉模型分析多张截图,支持文本提示词。 Args: request: 视觉聊天请求,包含截图ID列表和提示词 Returns: 视觉聊天响应,包含模型生成的文本和元信息 Raises: HTTPException: 当请求参数无效或API调用失败时 * @summary Vision Chat */ export type visionChatApiVisionChatPostResponse200 = { data: VisionChatResponse status: 200 } export type visionChatApiVisionChatPostResponse422 = { data: HTTPValidationError status: 422 } export type visionChatApiVisionChatPostResponseSuccess = (visionChatApiVisionChatPostResponse200) & { headers: Headers; }; export type visionChatApiVisionChatPostResponseError = (visionChatApiVisionChatPostResponse422) & { headers: Headers; }; export type visionChatApiVisionChatPostResponse = (visionChatApiVisionChatPostResponseSuccess | visionChatApiVisionChatPostResponseError) export const getVisionChatApiVisionChatPostUrl = () => { return `/api/vision/chat` } export const visionChatApiVisionChatPost = async (visionChatRequest: VisionChatRequest, options?: RequestInit): Promise => { return customFetcher(getVisionChatApiVisionChatPostUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, body: JSON.stringify( visionChatRequest,) } );} export const getVisionChatApiVisionChatPostMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: VisionChatRequest}, TContext>, request?: SecondParameter} ): UseMutationOptions>, TError,{data: VisionChatRequest}, TContext> => { const mutationKey = ['visionChatApiVisionChatPost']; const {mutation: mutationOptions, request: requestOptions} = options ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? options : {...options, mutation: {...options.mutation, mutationKey}} : {mutation: { mutationKey, }, request: undefined}; const mutationFn: MutationFunction>, {data: VisionChatRequest}> = (props) => { const {data} = props ?? {}; return visionChatApiVisionChatPost(data,requestOptions) } return { mutationFn, ...mutationOptions }} export type VisionChatApiVisionChatPostMutationResult = NonNullable>> export type VisionChatApiVisionChatPostMutationBody = VisionChatRequest export type VisionChatApiVisionChatPostMutationError = HTTPValidationError /** * @summary Vision Chat */ export const useVisionChatApiVisionChatPost = (options?: { mutation?:UseMutationOptions>, TError,{data: VisionChatRequest}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, {data: VisionChatRequest}, TContext > => { return useMutation(getVisionChatApiVisionChatPostMutationOptions(options), queryClient); } ================================================ FILE: free-todo-frontend/lib/hooks/useAutoRecording.ts ================================================ "use client"; import { useCallback, useEffect, useRef } from "react"; import { formatDateTime, getSegmentDate, } from "@/apps/audio/utils/timeUtils"; import { useConfig } from "@/lib/query"; import { useAudioRecordingStore } from "@/lib/store/audio-recording-store"; /** * 全局自动录音 Hook * * 在应用启动时,根据"自动启动录音"配置决定是否自动开始录音。 * - 配置开启:应用启动时自动开始录音 * - 配置关闭:需要用户在音频面板中手动点击"开始录音" * * 注意:此 hook 应该在应用入口(如 page.tsx)中调用一次 */ export function useAutoRecording() { const { data: config, isLoading: configLoading } = useConfig(); const autoStartEnabled = (config?.audioIs24x7 as boolean | undefined) ?? false; // 从全局 store 获取状态和方法 const isRecording = useAudioRecordingStore((state) => state.isRecording); const startRecording = useAudioRecordingStore((state) => state.startRecording); const updateLastFinalEnd = useAudioRecordingStore((state) => state.updateLastFinalEnd); const appendTranscriptionText = useAudioRecordingStore((state) => state.appendTranscriptionText); const setPartialText = useAudioRecordingStore((state) => state.setPartialText); const setOptimizedText = useAudioRecordingStore((state) => state.setOptimizedText); const appendSegmentData = useAudioRecordingStore((state) => state.appendSegmentData); const setLiveTodos = useAudioRecordingStore((state) => state.setLiveTodos); const setLiveSchedules = useAudioRecordingStore((state) => state.setLiveSchedules); const clearSessionData = useAudioRecordingStore((state) => state.clearSessionData); // 用于防止重复启动 const isStartingRef = useRef(false); const hasAutoStartedRef = useRef(false); // 启动录音的核心逻辑 const doStartRecording = useCallback(async () => { if (isRecording || isStartingRef.current) { console.log("[useAutoRecording] 已在录音中或正在启动,忽略启动请求"); return false; } console.log("[useAutoRecording] 开始启动录音..."); isStartingRef.current = true; try { // 清空会话数据 clearSessionData(); // 启动录音,始终使用 7×24 模式(启用分段保存和自动重连) await startRecording( (text, isFinal) => { // 处理分段保存通知 if (isFinal && text.startsWith("__SEGMENT_SAVED__")) { console.log("[useAutoRecording] 收到分段保存通知"); return; } if (isFinal) { // 获取 store 状态计算时间 const storeState = useAudioRecordingStore.getState(); const currentRecordingStartedAt = storeState.recordingStartedAt ?? Date.now(); const currentLastFinalEndMs = storeState.lastFinalEndMs; const segmentStartMs = currentLastFinalEndMs ?? currentRecordingStartedAt; const elapsedSec = (segmentStartMs - currentRecordingStartedAt) / 1000; // 更新时间戳 updateLastFinalEnd(Date.now()); // 追加转录文本 appendTranscriptionText(text); // 追加段落数据 const start = storeState.recordingStartedDate ?? new Date(); const segmentDate = getSegmentDate(start, elapsedSec, new Date()); appendSegmentData({ timeSec: elapsedSec, timeLabel: formatDateTime(segmentDate), recordingId: 0, offsetSec: elapsedSec, }); setPartialText(""); } else { setPartialText(text); } }, (data) => { if (typeof data.optimizedText === "string") setOptimizedText(data.optimizedText); if (Array.isArray(data.todos)) setLiveTodos(data.todos); if (Array.isArray(data.schedules)) setLiveSchedules(data.schedules); }, (error) => { console.error("[useAutoRecording] Recording error:", error); }, true // 始终使用 7×24 模式 ); console.log("[useAutoRecording] ✅ 录音启动成功"); return true; } catch (error) { console.error("[useAutoRecording] ❌ 启动录音失败:", error); return false; } finally { isStartingRef.current = false; } }, [ isRecording, clearSessionData, startRecording, updateLastFinalEnd, appendTranscriptionText, appendSegmentData, setPartialText, setOptimizedText, setLiveTodos, setLiveSchedules, ]); // 应用启动时自动开始录音(仅在配置开启时) useEffect(() => { // 等待配置加载完成 if (configLoading) { return; } // 如果已经自动启动过,不再重复启动 if (hasAutoStartedRef.current) { return; } // 如果配置开启且未在录音中,自动启动录音 if (autoStartEnabled && !isRecording && !isStartingRef.current) { console.log("[useAutoRecording] 自动启动录音已开启,准备自动启动..."); hasAutoStartedRef.current = true; // 延迟一点启动,确保应用完全初始化 const timer = setTimeout(() => { doStartRecording(); }, 1500); return () => { clearTimeout(timer); }; } }, [autoStartEnabled, isRecording, configLoading, doStartRecording]); return { isRecording, autoStartEnabled, configLoading, }; } ================================================ FILE: free-todo-frontend/lib/hooks/useOnboardingTour.ts ================================================ "use client"; import { type Driver, driver } from "driver.js"; import { useTranslations } from "next-intl"; import { useCallback, useRef } from "react"; import { useOnboardingStore } from "@/lib/store/onboarding-store"; import { useUiStore } from "@/lib/store/ui-store"; import { useOpenSettings } from "./useOpenSettings"; /** * 滚动设置面板到顶部 */ function scrollSettingsPanelToTop(): Promise { return new Promise((resolve) => { // 查找设置面板的滚动容器 const settingsContent = document.querySelector( '[data-tour="settings-content"]', ); if (settingsContent) { settingsContent.scrollTo({ top: 0, behavior: "smooth" }); // 等待滚动完成 setTimeout(resolve, 300); } else { resolve(); } }); } function selectSettingsCategory(category: string): void { window.dispatchEvent( new CustomEvent("settings:set-category", { detail: { category } }), ); } /** * Hook for managing the onboarding tour * Provides methods to start, skip, and check tour status */ export function useOnboardingTour() { const { hasCompletedTour, completeTour, setCurrentStep } = useOnboardingStore(); const { setDockDisplayMode } = useUiStore(); const { openSettings } = useOpenSettings(); const t = useTranslations("onboarding"); const driverRef = useRef(null); /** * Create and start the driver tour */ const createAndStartTour = useCallback(() => { // 引导期间保持 dock 固定显示 setDockDisplayMode("fixed"); const driverObj = driver({ showProgress: true, progressText: "{{current}} / {{total}}", allowClose: true, overlayColor: "#000", overlayOpacity: 0.7, stagePadding: 10, stageRadius: 8, animate: true, smoothScroll: true, allowKeyboardControl: true, // Button text nextBtnText: t("nextBtn"), prevBtnText: t("prevBtn"), doneBtnText: t("doneBtn"), // Custom popover class for styling popoverClass: "onboarding-popover", // Lifecycle hooks onHighlightStarted: (_element, _step, { state }) => { setCurrentStep(state.activeIndex ?? null); }, onDestroyed: () => { completeTour(); setCurrentStep(null); // 引导结束后保持 dock 固定显示并隐藏触发区域 setDockDisplayMode("fixed"); window.dispatchEvent(new Event("onboarding:hide-dock-trigger-zone")); }, steps: [ // Step 1: Welcome modal - 同时打开设置面板准备下一步 { popover: { title: t("welcomeTitle"), description: t("welcomeDescription"), side: "over" as const, align: "center" as const, }, onHighlightStarted: () => { // 在欢迎步骤就打开设置面板,为下一步做准备 openSettings(); // 滚动到顶部 setTimeout(() => { selectSettingsCategory("ai"); scrollSettingsPanelToTop(); }, 200); }, }, // Step 2: API Key 配置 { element: "#llm-api-key", popover: { title: t("apiKeyStepTitle"), description: t("apiKeyStepDescription"), side: "bottom" as const, align: "start" as const, }, onHighlightStarted: () => { // 确保元素可见 selectSettingsCategory("ai"); const element = document.getElementById("llm-api-key"); if (element) { element.scrollIntoView({ behavior: "smooth", block: "center" }); } }, }, // Step 3: Bottom Dock 功能介绍 { element: '[data-tour="bottom-dock"]', popover: { title: t("dockStepTitle"), description: t("dockStepDescription"), side: "top" as const, align: "center" as const, }, onHighlightStarted: () => { // 固定显示 dock setDockDisplayMode("fixed"); // 恢复 overlay 的点击阻止功能 const overlay = document.querySelector(".driver-overlay"); if (overlay) { (overlay as HTMLElement).style.pointerEvents = ""; } }, }, // Step 4: 右键点击引导(高亮 Panel B 的 dock item) { element: '[data-tour="dock-item-panelB"]', popover: { title: t("dockRightClickTitle"), description: t("dockRightClickDescription"), side: "top" as const, align: "center" as const, }, onHighlightStarted: () => { // 让 overlay 允许点击穿透,这样用户可以右键点击 const overlay = document.querySelector(".driver-overlay"); if (overlay) { (overlay as HTMLElement).style.pointerEvents = "none"; } // 监听 Panel B 上的右键点击事件 const panelBElement = document.querySelector( '[data-tour="dock-item-panelB"]', ); if (panelBElement) { const handleContextMenu = () => { // 用户已右键点击,菜单会由 BottomDock 自动打开 // 短暂延迟后进入下一步,等待菜单渲染 setTimeout(() => { driverObj.moveNext(); }, 100); // 移除监听器 panelBElement.removeEventListener( "contextmenu", handleContextMenu, ); }; panelBElement.addEventListener("contextmenu", handleContextMenu); // 存储清理函数 (window as unknown as Record void>) .__onboardingContextMenuCleanup = () => { panelBElement.removeEventListener( "contextmenu", handleContextMenu, ); }; } }, onDeselected: () => { // 清理事件监听器 const cleanup = (window as unknown as Record void>) .__onboardingContextMenuCleanup; if (cleanup) { cleanup(); delete (window as unknown as Record void>) .__onboardingContextMenuCleanup; } // 恢复 overlay 的点击阻止功能 const overlay = document.querySelector(".driver-overlay"); if (overlay) { (overlay as HTMLElement).style.pointerEvents = ""; } // 如果菜单还没打开(用户点击了"下一步"按钮),则程序化打开菜单 const menu = document.querySelector( '[data-tour="panel-selector-menu"]', ); if (!menu) { window.dispatchEvent( new CustomEvent("onboarding:open-dock-menu", { detail: { position: "panelB" }, }), ); } }, }, // Step 5: 右键菜单高亮(同时高亮 Panel B 和菜单) { element: '[data-tour="panel-selector-menu"]', popover: { title: t("dockMenuTitle"), description: t("dockMenuDescription"), side: "left" as const, align: "start" as const, }, onHighlightStarted: () => { // 确保菜单已打开(如果从其他方式进入此步骤) const menu = document.querySelector( '[data-tour="panel-selector-menu"]', ); if (!menu) { window.dispatchEvent( new CustomEvent("onboarding:open-dock-menu", { detail: { position: "panelB" }, }), ); } // 让 overlay 允许点击穿透,让用户可以点击菜单项 const overlay = document.querySelector(".driver-overlay"); if (overlay) { (overlay as HTMLElement).style.pointerEvents = "none"; } // 监听面板选择事件,用户选择任意面板后自动进入下一步 const handlePanelSelected = () => { // 短暂延迟后进入下一步,让面板切换动画完成 setTimeout(() => { driverObj.moveNext(); }, 150); // 移除监听器 window.removeEventListener( "onboarding:panel-selected", handlePanelSelected, ); }; window.addEventListener( "onboarding:panel-selected", handlePanelSelected, ); // 存储清理函数 (window as unknown as Record void>) .__onboardingPanelSelectedCleanup = () => { window.removeEventListener( "onboarding:panel-selected", handlePanelSelected, ); }; }, onDeselected: () => { // 清理事件监听器 const cleanup = (window as unknown as Record void>) .__onboardingPanelSelectedCleanup; if (cleanup) { cleanup(); delete (window as unknown as Record void>) .__onboardingPanelSelectedCleanup; } // 恢复 overlay 的点击阻止功能 const overlay = document.querySelector(".driver-overlay"); if (overlay) { (overlay as HTMLElement).style.pointerEvents = ""; } }, }, // Step 6: Completion modal { popover: { title: t("completeTitle"), description: t("completeDescription"), side: "over" as const, align: "center" as const, }, }, ], }); driverRef.current = driverObj; driverObj.drive(); }, [completeTour, setCurrentStep, setDockDisplayMode, openSettings, t]); /** * Start the onboarding tour (only if not completed) */ const startTour = useCallback(() => { if (hasCompletedTour) return; createAndStartTour(); }, [hasCompletedTour, createAndStartTour]); /** * Restart the tour (reset state and start immediately) * This is used when the user wants to see the tour again */ const restartTour = useCallback(() => { // Reset the tour state first useOnboardingStore.getState().resetTour(); // Start the tour after a short delay to ensure state is updated setTimeout(() => { createAndStartTour(); }, 100); }, [createAndStartTour]); /** * Skip the tour without completing it */ const skipTour = useCallback(() => { if (driverRef.current) { driverRef.current.destroy(); } completeTour(); }, [completeTour]); /** * Reset the tour state to allow re-onboarding */ const resetTour = useCallback(() => { useOnboardingStore.getState().resetTour(); }, []); return { startTour, restartTour, skipTour, resetTour, hasCompletedTour, }; } ================================================ FILE: free-todo-frontend/lib/hooks/useOpenSettings.ts ================================================ "use client"; import { useCallback } from "react"; import { useUiStore } from "@/lib/store/ui-store"; /** * 计算各面板的实际宽度比例 * 返回各面板的实际显示宽度(0~1) */ function calculatePanelWidths( isPanelAOpen: boolean, isPanelBOpen: boolean, isPanelCOpen: boolean, panelAWidth: number, panelCWidth: number, ): { panelA: number; panelB: number; panelC: number } { // 计算基础宽度(不包括 panelC) const baseWidth = isPanelCOpen ? 1 - panelCWidth : 1; const actualPanelCWidth = isPanelCOpen ? panelCWidth : 0; // 所有面板都关闭 if (!isPanelAOpen && !isPanelBOpen && !isPanelCOpen) { return { panelA: 0, panelB: 0, panelC: 0 }; } // 三个面板都打开 if (isPanelAOpen && isPanelBOpen && isPanelCOpen) { return { panelA: panelAWidth * baseWidth, panelB: (1 - panelAWidth) * baseWidth, panelC: actualPanelCWidth, }; } // panelA 和 panelB 打开 if (isPanelAOpen && isPanelBOpen) { return { panelA: panelAWidth, panelB: 1 - panelAWidth, panelC: 0, }; } // panelB 和 panelC 打开 if (isPanelBOpen && isPanelCOpen) { return { panelA: 0, panelB: baseWidth, panelC: actualPanelCWidth, }; } // panelA 和 panelC 打开 if (isPanelAOpen && isPanelCOpen) { return { panelA: baseWidth, panelB: 0, panelC: actualPanelCWidth, }; } // 只有 panelA 打开 if (isPanelAOpen) { return { panelA: 1, panelB: 0, panelC: 0 }; } // 只有 panelB 打开 if (isPanelBOpen) { return { panelA: 0, panelB: 1, panelC: 0 }; } // 只有 panelC 打开 return { panelA: 0, panelB: 0, panelC: 1 }; } /** * 提供打开设置页面的功能 * 复用于 SettingsToggle 和 HeaderIsland 等组件 * * 打开设置的逻辑: * - 如果 Panel B 已激活,直接切换 Panel B 到设置 * - 否则找到最宽的 Panel(A 或 C),激活并切换到设置 */ export function useOpenSettings() { const { isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth, panelFeatureMap, setPanelFeature, togglePanelA, togglePanelB, togglePanelC, } = useUiStore(); /** * 计算各面板的实际宽度比例 */ const getPanelWidths = useCallback(() => { return calculatePanelWidths( isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth, ); }, [isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth]); /** * 打开设置页面 */ const openSettings = useCallback(() => { // 检查当前是否已经有面板显示设置 const isSettingsInA = panelFeatureMap.panelA === "settings"; const isSettingsInB = panelFeatureMap.panelB === "settings"; const isSettingsInC = panelFeatureMap.panelC === "settings"; // 如果设置已经在某个打开的面板中显示,不做任何操作 if ( (isSettingsInA && isPanelAOpen) || (isSettingsInB && isPanelBOpen) || (isSettingsInC && isPanelCOpen) ) { return; } // 情况 1: Panel B 已激活,直接切换到设置 if (isPanelBOpen) { setPanelFeature("panelB", "settings"); return; } // 情况 2: Panel B 未激活,找最宽的面板 const widths = getPanelWidths(); // 如果没有面板打开,打开 Panel B 并设置为设置 if (widths.panelA === 0 && widths.panelC === 0) { togglePanelB(); setPanelFeature("panelB", "settings"); return; } // 找到最宽的面板并激活/切换到设置 if (widths.panelA >= widths.panelC) { // Panel A 更宽或相等 if (isPanelAOpen) { setPanelFeature("panelA", "settings"); } else { togglePanelA(); setPanelFeature("panelA", "settings"); } } else { // Panel C 更宽 if (isPanelCOpen) { setPanelFeature("panelC", "settings"); } else { togglePanelC(); setPanelFeature("panelC", "settings"); } } }, [ isPanelAOpen, isPanelBOpen, isPanelCOpen, panelFeatureMap, setPanelFeature, togglePanelA, togglePanelB, togglePanelC, getPanelWidths, ]); return { openSettings, getPanelWidths }; } ================================================ FILE: free-todo-frontend/lib/hooks/usePanelLayout.ts ================================================ /** * Panel 布局计算 Hook * 计算 Panel A、B、C 的显示状态和宽度 */ import { useMemo } from "react"; interface UsePanelLayoutOptions { isPanelAOpen: boolean; isPanelBOpen: boolean; isPanelCOpen: boolean; panelAWidth: number; panelCWidth: number; } export function usePanelLayout({ isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth, }: UsePanelLayoutOptions) { const layoutState = useMemo(() => { // 计算基础宽度(不包括 panelC) const baseWidth = isPanelCOpen ? 1 - panelCWidth : 1; const actualPanelCWidth = isPanelCOpen ? panelCWidth : 0; // 所有面板都关闭的情况 if (!isPanelAOpen && !isPanelBOpen && !isPanelCOpen) { return { showPanelA: false, showPanelB: false, showPanelC: false, panelAWidth: 0, panelBWidth: 0, panelCWidth: 0, showPanelAResizeHandle: false, showPanelCResizeHandle: false, }; } if (isPanelAOpen && isPanelBOpen && isPanelCOpen) { // 三个面板都打开 return { showPanelA: true, showPanelB: true, showPanelC: true, panelAWidth: panelAWidth * baseWidth, panelBWidth: (1 - panelAWidth) * baseWidth, panelCWidth: actualPanelCWidth, showPanelAResizeHandle: true, showPanelCResizeHandle: true, }; } if (isPanelAOpen && isPanelBOpen) { // 只有 panelA 和 panelB 打开 return { showPanelA: true, showPanelB: true, showPanelC: false, panelAWidth: panelAWidth, panelBWidth: 1 - panelAWidth, panelCWidth: 0, showPanelAResizeHandle: true, showPanelCResizeHandle: false, }; } if (isPanelBOpen && isPanelCOpen) { // 只有 panelB 和 panelC 打开 return { showPanelA: false, showPanelB: true, showPanelC: true, panelAWidth: 0, panelBWidth: baseWidth, panelCWidth: actualPanelCWidth, showPanelAResizeHandle: false, showPanelCResizeHandle: true, }; } if (isPanelAOpen && isPanelCOpen) { // 只有 panelA 和 panelC 打开 return { showPanelA: true, showPanelB: false, showPanelC: true, panelAWidth: baseWidth, panelBWidth: 0, panelCWidth: actualPanelCWidth, showPanelAResizeHandle: false, showPanelCResizeHandle: true, }; } if (isPanelAOpen && !isPanelBOpen) { // 只有 panelA 打开 return { showPanelA: true, showPanelB: false, showPanelC: isPanelCOpen, panelAWidth: baseWidth, panelBWidth: 0, panelCWidth: actualPanelCWidth, showPanelAResizeHandle: false, showPanelCResizeHandle: isPanelCOpen, }; } if (!isPanelAOpen && isPanelBOpen) { // 只有 panelB 打开 return { showPanelA: false, showPanelB: true, showPanelC: isPanelCOpen, panelAWidth: 0, panelBWidth: baseWidth, panelCWidth: actualPanelCWidth, showPanelAResizeHandle: false, showPanelCResizeHandle: isPanelCOpen, }; } // 只有 panelC 打开 return { showPanelA: false, showPanelB: false, showPanelC: true, panelAWidth: 0, panelBWidth: 0, panelCWidth: actualPanelCWidth, showPanelAResizeHandle: false, showPanelCResizeHandle: false, }; }, [isPanelAOpen, isPanelBOpen, isPanelCOpen, panelAWidth, panelCWidth]); return layoutState; } ================================================ FILE: free-todo-frontend/lib/hooks/usePanelResize.ts ================================================ /** * Panel A 和 Panel C 调整大小 Hook * 处理 Panel A 和 Panel C 的宽度调整逻辑 */ import type { PointerEvent as ReactPointerEvent } from "react"; import { useCallback } from "react"; interface UsePanelResizeOptions { containerRef: React.RefObject; isPanelBOpen: boolean; isPanelCOpen: boolean; panelCWidth: number; setPanelAWidth: (width: number) => void; setPanelCWidth: (width: number) => void; setIsDraggingPanelA: (isDragging: boolean) => void; setIsDraggingPanelC: (isDragging: boolean) => void; setGlobalResizeCursor: (enabled: boolean) => void; } export function usePanelResize({ containerRef, isPanelBOpen, isPanelCOpen, panelCWidth, setPanelAWidth, setPanelCWidth, setIsDraggingPanelA, setIsDraggingPanelC, setGlobalResizeCursor, }: UsePanelResizeOptions) { const handlePanelADragAtClientX = useCallback( (clientX: number) => { const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); if (rect.width <= 0) return; const relativeX = clientX - rect.left; const ratio = relativeX / rect.width; // 当 panelC 打开时,panelA 的宽度是相对于 baseWidth 的比例 // baseWidth = 1 - panelCWidth // 所以需要将 ratio 转换为相对于 baseWidth 的比例 if (isPanelCOpen && isPanelBOpen) { const baseWidth = 1 - panelCWidth; if (baseWidth > 0) { const adjustedRatio = ratio / baseWidth; setPanelAWidth(adjustedRatio); } else { setPanelAWidth(0.5); } } else { setPanelAWidth(ratio); } }, [setPanelAWidth, isPanelCOpen, isPanelBOpen, panelCWidth, containerRef], ); const handlePanelCDragAtClientX = useCallback( (clientX: number) => { const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); if (rect.width <= 0) return; const relativeX = clientX - rect.left; const ratio = relativeX / rect.width; // panelCWidth 是从右侧开始计算的,所以是 1 - ratio setPanelCWidth(1 - ratio); }, [setPanelCWidth, containerRef], ); const handlePanelAResizePointerDown = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); setIsDraggingPanelA(true); setGlobalResizeCursor(true); handlePanelADragAtClientX(event.clientX); const handlePointerMove = (moveEvent: PointerEvent) => { handlePanelADragAtClientX(moveEvent.clientX); }; const handlePointerUp = () => { setIsDraggingPanelA(false); setGlobalResizeCursor(false); window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); }, [handlePanelADragAtClientX, setIsDraggingPanelA, setGlobalResizeCursor], ); const handlePanelCResizePointerDown = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); setIsDraggingPanelC(true); setGlobalResizeCursor(true); handlePanelCDragAtClientX(event.clientX); const handlePointerMove = (moveEvent: PointerEvent) => { handlePanelCDragAtClientX(moveEvent.clientX); }; const handlePointerUp = () => { setIsDraggingPanelC(false); setGlobalResizeCursor(false); window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); }, [handlePanelCDragAtClientX, setIsDraggingPanelC, setGlobalResizeCursor], ); return { handlePanelAResizePointerDown, handlePanelCResizePointerDown, }; } ================================================ FILE: free-todo-frontend/lib/hooks/usePanelWindowResize.ts ================================================ /** * Panel 窗口调整大小 Hook * 处理 Panel 窗口的宽度调整逻辑 */ import { useCallback } from "react"; import { getElectronAPI } from "@/lib/utils/electron-api"; interface UsePanelWindowResizeOptions { panelWindowWidth: number; panelWindowPosition: { x: number; y: number }; panelWindowHeight: number; isElectron: boolean; MIN_PANEL_WIDTH: number; MAX_PANEL_WIDTH: number; MIN_PANEL_HEIGHT: number; MAX_PANEL_HEIGHT: number; setPanelWindowWidth: (width: number) => void; setPanelWindowPosition: (position: { x: number; y: number }) => void; setPanelWindowHeight: (height: number) => void; setIsResizingPanel: (isResizing: boolean) => void; setIsUserInteracting: (isInteracting: boolean) => void; } export function usePanelWindowResize({ panelWindowWidth, panelWindowPosition, panelWindowHeight, isElectron, MIN_PANEL_WIDTH, MAX_PANEL_WIDTH, MIN_PANEL_HEIGHT, MAX_PANEL_HEIGHT, setPanelWindowWidth, setPanelWindowPosition, setPanelWindowHeight, setIsResizingPanel, setIsUserInteracting, }: UsePanelWindowResizeOptions) { const handlePanelResizeStart = useCallback((e: React.PointerEvent | React.MouseEvent, resizeSide: 'left' | 'right' | 'top' | 'bottom') => { e.preventDefault(); e.stopPropagation(); // ✅ 设置交互标志,防止定时器干扰 setIsUserInteracting(true); setIsResizingPanel(true); // ✅ 立即禁用点击穿透(只调用一次,避免频繁 IPC 调用) if (isElectron) { const api = getElectronAPI(); api.electronAPI?.setIgnoreMouseEvents?.(false); } const startX = e.clientX; const startY = e.clientY; const startWidth = panelWindowWidth; // ✅ 修复:参考左右调整的实现,只在开始时获取一次高度,避免卡顿 // panelWindowHeight 是 PanelRegion 的高度(包括 Panels 容器 + BottomDock 60px) // 如果 panelWindowHeight 为 0,使用默认 PanelRegion 高度计算 let actualStartHeight: number; if (panelWindowHeight > 0) { actualStartHeight = panelWindowHeight; // PanelRegion 高度 } else { // 如果高度为 0,使用默认 PanelRegion 高度(参考左右调整,不查询 DOM) // PanelRegion 高度 = 窗口高度 - 顶部偏移(40px) - 标题栏(48px) actualStartHeight = typeof window !== 'undefined' ? window.innerHeight - 40 - 48 : 1000; } const startHeight = actualStartHeight; // 这是 PanelRegion 的高度 const startXPosition = panelWindowPosition.x; const startYPosition = panelWindowPosition.y; // ✅ 参考左右调整的完美实现:不使用 requestAnimationFrame,直接更新状态 const handlePointerMove = (moveEvent: PointerEvent | MouseEvent) => { if (resizeSide === 'top') { // ✅ 顶部调整:参考左边调整的逻辑 // 向上拖拽(deltaY < 0)增加高度,向下拖拽(deltaY > 0)减少高度 // deltaY = moveEvent.clientY - startY // 向上拖拽时,鼠标向上移动,deltaY < 0,高度应该增加 // 向下拖拽时,鼠标向下移动,deltaY > 0,高度应该减少 // 所以:newHeight = startHeight - deltaY const deltaY = moveEvent.clientY - startY; const newHeight = Math.max( MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, startHeight - deltaY) ); // 当高度改变时,y 位置也需要调整,以保持底部边界不变 // 高度增加了 deltaHeight,y 位置需要减少相同的量(向上移动) const deltaHeight = newHeight - startHeight; const newY = Math.max(0, startYPosition - deltaHeight); setPanelWindowHeight(newHeight); setPanelWindowPosition({ ...panelWindowPosition, y: newY }); } else if (resizeSide === 'bottom') { // ✅ 底部调整:参考右边调整的逻辑 // 向下拖拽(deltaY > 0)增加高度,向上拖拽(deltaY < 0)减少高度 // deltaY = moveEvent.clientY - startY // 向下拖拽时,鼠标向下移动,deltaY > 0,高度应该增加 // 向上拖拽时,鼠标向上移动,deltaY < 0,高度应该减少 // 所以:newHeight = startHeight + deltaY const deltaY = moveEvent.clientY - startY; const newHeight = Math.max( MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, startHeight + deltaY) ); // 底部调整时,y 位置不变,只改变高度 setPanelWindowHeight(newHeight); } else if (resizeSide === 'left') { // 左边调整:向左拖拽增加宽度,向右拖拽减少宽度 // deltaX > 0 表示向左移动(增加宽度),deltaX < 0 表示向右移动(减少宽度) const deltaX = startX - moveEvent.clientX; const newWidth = Math.max( MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, startWidth + deltaX) ); // 当宽度改变时,x 位置也需要调整,以保持右边界不变 // 宽度增加了 deltaWidth,x 位置需要减少相同的量 const deltaWidth = newWidth - startWidth; const newX = Math.max(0, startXPosition - deltaWidth); setPanelWindowWidth(newWidth); setPanelWindowPosition({ ...panelWindowPosition, x: newX }); } else { // 右边调整:向右拖拽增加宽度,向左拖拽减少宽度 // deltaX < 0 表示向左移动(减少宽度),deltaX > 0 表示向右移动(增加宽度) const deltaX = moveEvent.clientX - startX; const newWidth = Math.max( MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, startWidth + deltaX) ); // 右边调整时,x 位置不变,只改变宽度 setPanelWindowWidth(newWidth); } }; const handlePointerUp = () => { setIsResizingPanel(false); // ✅ 清除交互标志 setIsUserInteracting(false); // ✅ 清理后确保点击穿透仍然关闭 if (isElectron) { const api = getElectronAPI(); api.electronAPI?.setIgnoreMouseEvents?.(false); } document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerUp); document.removeEventListener("mousemove", handlePointerMove); document.removeEventListener("mouseup", handlePointerUp); }; // 同时监听 pointer 和 mouse 事件以确保兼容性 document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); document.addEventListener("mousemove", handlePointerMove); document.addEventListener("mouseup", handlePointerUp); }, [panelWindowWidth, panelWindowPosition, panelWindowHeight, isElectron, MIN_PANEL_WIDTH, MAX_PANEL_WIDTH, MIN_PANEL_HEIGHT, MAX_PANEL_HEIGHT, setPanelWindowWidth, setPanelWindowPosition, setPanelWindowHeight, setIsResizingPanel, setIsUserInteracting]); return { handlePanelResizeStart }; } ================================================ FILE: free-todo-frontend/lib/hooks/usePanelWindowStyles.ts ================================================ /** * Panel 窗口样式管理 Hook * 确保 Panel 窗口在拖动和调整大小时保持可见 */ import { useLayoutEffect } from "react"; interface UsePanelWindowStylesOptions { isPanelMode: boolean; panelWindowHeight: number; } export function usePanelWindowStyles({ isPanelMode, panelWindowHeight, }: UsePanelWindowStylesOptions) { // ✅ 关键修复:监听拖动状态,确保 Panel DOM 元素在拖动时保持可见 // 使用 useLayoutEffect + 三重 requestAnimationFrame 确保在 React 应用 style 之后执行 useLayoutEffect(() => { if (!isPanelMode) return; // 使用三重 requestAnimationFrame 确保在 React 应用 style prop 之后执行 // 这样设置的样式不会被 React 的 style prop 覆盖 requestAnimationFrame(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { const panelWindow = document.querySelector('[data-panel-window]') as HTMLElement; if (!panelWindow) return; // 强制设置样式,使用 !important 确保优先级高于 React 的 style prop panelWindow.style.setProperty('opacity', '1', 'important'); panelWindow.style.setProperty('background-color', 'white', 'important'); panelWindow.style.setProperty('background', 'white', 'important'); panelWindow.style.setProperty('visibility', 'visible', 'important'); panelWindow.style.setProperty('display', 'flex', 'important'); panelWindow.style.setProperty('z-index', '1000001', 'important'); // ✅ 确保高于 DynamicIsland panelWindow.style.setProperty('position', 'fixed', 'important'); // ✅ 移除 bottom,使用固定高度,不随 y 位置变化 panelWindow.style.removeProperty('bottom'); // 保持高度固定,避免拖动时高度变化 // panelWindowHeight 是 PanelRegion 高度(包括 Panels 容器 + BottomDock 60px),窗口总高度 = 标题栏(48px) + panelWindowHeight const heightValue = panelWindowHeight > 0 ? `${panelWindowHeight + 48}px` : `calc(100vh - 40px)`; panelWindow.style.setProperty('height', heightValue, 'important'); }); }); }); }, [isPanelMode, panelWindowHeight]); } ================================================ FILE: free-todo-frontend/lib/hooks/useTodoCapture.ts ================================================ "use client"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { queryKeys } from "@/lib/query/keys"; import { toastError, toastInfo, toastSuccess } from "@/lib/toast"; import { getElectronAPI } from "@/lib/utils/electron-api"; interface ExtractedTodoResponse { title: string; description?: string; time_info?: Record; source_text?: string; confidence: number; } interface CaptureExtractResult { success: boolean; message?: string; extractedTodos: ExtractedTodoResponse[]; createdCount?: number; } /** * 截图并提取待办的 Hook * 封装截图+提取逻辑,管理 Loading/Success/Error 状态 */ export function useTodoCapture() { const [isCapturing, setIsCapturing] = useState(false); const [result, setResult] = useState(null); const queryClient = useQueryClient(); const captureAndExtract = useCallback(async () => { const api = getElectronAPI(); // 检查是否在 Electron 环境中 if (!api.electronAPI?.captureAndExtractTodos) { toastError("请在桌面应用中使用此功能"); return null; } try { setIsCapturing(true); setResult(null); toastInfo("正在截图..."); // 获取 panel 的位置信息(如果存在) let panelBounds: { x: number; y: number; width: number; height: number } | null = null; try { const panelElement = document.querySelector('[data-panel-window]') as HTMLElement; if (panelElement) { const rect = panelElement.getBoundingClientRect(); // getBoundingClientRect 返回的是相对于视口的位置 // 在 Electron 中,窗口坐标就是相对于窗口左上角的 // 后端会加上窗口在屏幕上的位置来得到屏幕坐标 panelBounds = { x: rect.left, y: rect.top, width: rect.width, height: rect.height, }; } } catch (error) { console.warn("Failed to get panel bounds:", error); } // 调用 Electron API 截图并提取待办 const response = await api.electronAPI.captureAndExtractTodos(panelBounds); if (response.success) { const createdCount = response.createdCount ?? 0; if (createdCount > 0) { // 后端已直接创建了 draft 状态的待办 toastSuccess(`已创建 ${createdCount} 个待办事项(草稿状态)`); // 强制刷新待办列表 queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); } else if (response.extractedTodos.length > 0) { // 提取到了但未创建(理论上不应该发生,因为 create_todos=true) toastInfo(`已提取 ${response.extractedTodos.length} 个待办事项,但未创建`); } else { toastInfo("未检测到待办事项"); } const captureResult: CaptureExtractResult = { success: true, message: response.message, extractedTodos: response.extractedTodos, createdCount, }; setResult(captureResult); return captureResult; } else { const errorMessage = response.message || "提取失败"; toastError(errorMessage); setResult({ success: false, message: errorMessage, extractedTodos: [], createdCount: 0, }); return null; } } catch (error) { const errorMessage = error instanceof Error ? error.message : "未知错误"; toastError(`提取待办失败: ${errorMessage}`); setResult({ success: false, message: errorMessage, extractedTodos: [], createdCount: 0, }); return null; } finally { setIsCapturing(false); } }, [queryClient]); const clearResult = useCallback(() => { setResult(null); }, []); return { isCapturing, result, captureAndExtract, clearResult, }; } ================================================ FILE: free-todo-frontend/lib/hooks/useWindowAdaptivePanels.ts ================================================ "use client"; import { useEffect, useRef } from "react"; import type { PanelPosition } from "@/lib/config/panel-config"; import { useUiStore } from "@/lib/store/ui-store"; const MIN_PANEL_WIDTH_PX = 300; /** * Hook for adaptive panel management based on window width * Automatically closes/opens panels when window width changes */ export function useWindowAdaptivePanels( containerRef: React.RefObject, ) { const { setAutoClosePanel, restoreAutoClosedPanel } = useUiStore(); // 使用ref来存储上一次的宽度,避免重复计算 const lastWidthRef = useRef(0); const timeoutRef = useRef(null); // 使用ref存储store的getState函数,避免依赖变化 const storeRef = useRef(useUiStore.getState()); // 更新store引用 useEffect(() => { storeRef.current = useUiStore.getState(); }); useEffect(() => { const container = containerRef.current; if (!container) return; // 计算当前打开的panel数量 const getOpenPanelCount = (): number => { const state = storeRef.current; let count = 0; if (state.isPanelAOpen) count++; if (state.isPanelBOpen) count++; if (state.isPanelCOpen) count++; return count; }; // 找到最右侧打开的panel const getRightmostOpenPanel = (): PanelPosition | null => { const state = storeRef.current; // 优先级:panelC > panelB > panelA(从右到左) if (state.isPanelCOpen) return "panelC"; if (state.isPanelBOpen) return "panelB"; if (state.isPanelAOpen) return "panelA"; return null; }; // 处理窗口宽度变化 const handleResize = () => { // 清除之前的timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // 防抖处理:200ms延迟 timeoutRef.current = setTimeout(() => { const rect = container.getBoundingClientRect(); const containerWidth = rect.width; // 如果宽度没有变化,跳过 if (Math.abs(containerWidth - lastWidthRef.current) < 1) { return; } lastWidthRef.current = containerWidth; // 计算能容纳的最大panel数量 const maxPanels = Math.floor(containerWidth / MIN_PANEL_WIDTH_PX); const openPanelCount = getOpenPanelCount(); const state = storeRef.current; // 如果打开的panel数量超过能容纳的数量,关闭最右侧的panel if (openPanelCount > maxPanels) { const rightmostPanel = getRightmostOpenPanel(); if (rightmostPanel) { setAutoClosePanel(rightmostPanel); } } // 如果打开的panel数量小于能容纳的数量,且有自动关闭的panel,恢复最近关闭的panel else if ( openPanelCount < maxPanels && state.autoClosedPanels.length > 0 ) { restoreAutoClosedPanel(); } }, 200); }; // 使用ResizeObserver监听容器宽度变化 const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(container); // 初始检查 handleResize(); // 清理函数 return () => { resizeObserver.disconnect(); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, [containerRef, setAutoClosePanel, restoreAutoClosedPanel]); } ================================================ FILE: free-todo-frontend/lib/i18n/messages/en.json ================================================ { "language": { "zh": "中文", "en": "English" }, "colorTheme": { "catppuccin": "Catppuccin", "blue": "Blue", "neutral": "Neutral", "label": "Theme Style" }, "theme": { "light": "Light", "dark": "Dark", "system": "System" }, "layout": { "currentLanguage": "Current Language", "currentTheme": "Current Theme", "userSettings": "User Settings", "openSettings": "Open Settings" }, "page": { "title": "Free Todo Canvas", "subtitle": "Calendar and Todos view side by side. Toggle via bottom dock and resize panels by dragging the handle.", "calendarLabel": "Calendar View", "calendarPlaceholder": "Placeholder: plug a calendar component here", "activityLabel": "Activity Stream", "activityPlaceholder": "Placeholder: plug an activity dashboard here", "todosLabel": "Todos View", "todosPlaceholder": "Placeholder: plug a Todo list here", "todoListTitle": "Todos", "chatLabel": "AI Chat", "chatPlaceholder": "Placeholder: plug an AI chat component here", "chatTitle": "Free Todo - AI Assistant", "chatSubtitle": "A personalized AI chat app that helps you manage todos and boost productivity.", "chatQuestion": "How can I help you today?", "chatSuggestion1": "Break down today's todos and prioritize", "chatSuggestion2": "Plan my week with calendar and todos", "chatSuggestion3": "Summarize project tasks and next steps", "chatInputPlaceholder": "I am working on", "chatSendButton": "Send", "chatHistory": "History", "newChat": "New chat", "recentSessions": "Recent sessions", "noHistory": "No history yet", "messagesCount": "{count} messages", "loadHistoryFailed": "Failed to load history", "loadSessionFailed": "Failed to load session", "sessionLoaded": "Session loaded", "todoDetailLabel": "Todo Detail", "todoDetailPlaceholder": "Placeholder: plug a todo detail component here", "diaryLabel": "Diary", "diaryPlaceholder": "Placeholder: plug a diary component here", "settingsLabel": "Settings", "settingsPlaceholder": "Placeholder: plug a settings component here", "costTrackingLabel": "Cost Tracking", "costTrackingPlaceholder": "Placeholder: view cost tracking here", "achievementsLabel": "Achievements", "achievementsPlaceholder": "Placeholder: plug an achievements component here", "screenshotsLabel": "Screenshots (Debug)", "screenshotsPlaceholder": "Event timeline with screenshots (dev mode only)", "debugShotsLabel": "Debug Shots", "debugShotsPlaceholder": "Placeholder: manage debugging screenshots (dev mode only)", "audioLabel": "Audio Recording", "audioPlaceholder": "Placeholder: plug an audio recording component here", "backendUnavailableBadge": "Backend unavailable", "backendUnavailableTooltip": "Backend module is disabled or missing dependencies.", "backendUnavailableTitle": "Feature unavailable", "backendUnavailableDescription": "Backend module is disabled or missing dependencies.", "audioRecording": "Recording", "audioRecordingStopped": "Stopped", "audioStandby": "Standby", "audioStartRecording": "Start Recording", "audioStopRecording": "Stop Recording", "audioRecordingStatus": "Recording Status", "settings": { "categoryWorkspaceTitle": "Workspace & Panels", "categoryWorkspaceDescription": "Adjust dock behavior and panel visibility.", "categoryAutomationTitle": "Automation", "categoryAutomationDescription": "Manage automation features like auto todo detection.", "categoryAiTitle": "AI & Integrations", "categoryAiDescription": "Configure LLM and web search services.", "categoryDeveloperTitle": "Developer & Advanced", "categoryDeveloperDescription": "Advanced and experimental settings (recording, audio, scheduler).", "categoryHelpTitle": "Help & About", "categoryHelpDescription": "Onboarding tour, version info, and help.", "searchPlaceholder": "Search settings...", "searchNoResultsTitle": "No matching settings", "searchNoResultsHint": "Try a different keyword.", "aboutTitle": "Version & Info", "aboutDescription": "View current version and build details.", "journalSettingsTitle": "Journal Settings", "journalSettingsDescription": "Configure daily reset and auto generation for journals.", "journalRefreshModeLabel": "Refresh mode", "journalRefreshModeFixed": "Fixed time", "journalRefreshModeWorkHours": "Work hours", "journalRefreshModeCustom": "Custom time", "journalFixedTimeLabel": "Fixed time", "journalWorkHoursLabel": "Work hours", "journalCustomTimeLabel": "Custom time", "journalAutoLinkLabel": "Auto-link after save", "journalAutoObjectiveLabel": "Auto-generate objective log", "journalAutoAiLabel": "Auto-generate AI view", "autoTodoDetectionTitle": "Auto Todo Detection", "autoTodoDetectionDescription": "Automatically detect todos from screenshots of whitelisted apps and create draft todos for confirmation", "autoTodoDetectionLabel": "Enable Auto Todo Detection", "autoTodoDetectionHint": "Enabled: System will automatically detect todos from whitelisted apps", "autoTodoDetectionEnabled": "Auto todo detection enabled", "autoTodoDetectionDisabled": "Auto todo detection disabled", "whitelistApps": "App Whitelist", "whitelistAppsPlaceholder": "Enter app name and press Enter to add", "whitelistAppsDesc": "Only screenshots from these apps will trigger auto todo detection", "loadFailed": "Failed to load config: {error}", "saveFailed": "Failed to save config: {error}", "saveSuccess": "Configuration saved", "costTrackingPanelTitle": "Cost Panel", "costTrackingPanelDescription": "Control whether the cost tracking feature is available in panels", "costTrackingPanelLabel": "Enable cost tracking panel", "costTrackingPanelHint": "Disable to hide the cost tracking panel from the selector", "costTrackingPanelEnabled": "Cost tracking panel enabled", "costTrackingPanelDisabled": "Cost tracking panel disabled", "panelSwitchesTitle": "Panel Switches", "panelSwitchesDescription": "Control the visibility of each panel", "panelEnabled": "Enabled", "panelDisabled": "Disabled", "llmConfig": "LLM Configuration", "difyConfigTitle": "Dify Test Configuration", "difyEnabledLabel": "Enable Dify test mode", "difyEnabledDescription": "When enabled, the Dify Test mode in chat will use Dify API for responses.", "difySaveSuccess": "Dify configuration saved", "tavilyConfigTitle": "Tavily Web Search Configuration", "tavilySaveSuccess": "Tavily configuration saved", "tavilyApiKeyHint": "How to get API Key? Please visit", "tavilyApiKeyLink": "Tavily Console", "save": "Save", "apiKey": "API Key", "baseUrl": "Base URL", "model": "Model", "temperature": "Temperature", "maxTokens": "Max Tokens", "testConnection": "Test API Connection", "testSuccess": "✓ API configuration verified successfully!", "testFailed": "✗ API configuration verification failed", "apiKeyRequired": "Please fill in API Key and Base URL", "apiKeyHint": "How to get API Key? Please visit", "apiKeyLink": "Aliyun Bailian Console", "basicSettings": "Screen Recording Settings", "enableRecording": "Enable Recording", "enableRecordingDesc": "Enable screen recording and screenshot features", "screenshotInterval": "Screenshot Interval (seconds)", "enableBlacklist": "Enable Blacklist (for screenshot debug panel and activity panel)", "enableBlacklistDesc": "Enable to set apps that do not need screenshots", "appBlacklist": "App Blacklist", "blacklistPlaceholder": "Enter app name and press Enter to add", "blacklistDesc": "Windows of these apps will not be recorded", "dockDisplayModeTitle": "Dock Display Mode", "dockDisplayModeDescription": "Control the display behavior of the bottom dock", "dockDisplayModeLabel": "Display Mode", "dockDisplayModeFixed": "Always Visible", "dockDisplayModeAutoHide": "Auto Hide", "dockDisplayModeChanged": "Dock display mode changed", "notificationPermissionTitle": "Notification Permission", "notificationPermissionDescription": "Manually request system notification permission (web and Tauri).", "notificationPermissionStatusLabel": "Permission status", "notificationPermissionStatusGranted": "Granted", "notificationPermissionStatusDenied": "Denied", "notificationPermissionStatusDefault": "Not granted", "notificationPermissionStatusUnknown": "Unknown", "notificationPermissionStatusNotRequired": "Not required", "notificationPermissionRequest": "Request notification permission", "notificationPermissionRequesting": "Requesting...", "notificationPermissionRequestSuccess": "Notification permission granted", "notificationPermissionRequestDenied": "Notification permission denied", "notificationPermissionRequestFailed": "Failed to request permission: {error}", "notificationPermissionNotSupported": "Notification permission is not supported here", "notificationPermissionHint": "After granting, reminders will show in system notifications.", "notificationPermissionElectronHint": "Electron requests permission automatically on first notification.", "developerSectionTitle": "Developer Options", "developerSectionDescription": "Advanced and experimental settings for developers or power users.", "devPanelsTitle": "Panels in Development", "devPanelsDescription": "These panels are still under development. They are hidden from the dock by default; you can enable them here.", "modeSwitcherTitle": "Chat Mode Switcher", "modeSwitcherDescription": "Control whether the chat panel shows Ask/Plan/Edit mode switching options", "modeSwitcherLabel": "Show Mode Switcher", "modeSwitcherHint": "When disabled, chat panel will only use Ask mode", "modeSwitcherEnabled": "Mode switcher enabled", "modeSwitcherDisabled": "Mode switcher disabled", "defaultChatModeLabel": "Default Chat Mode", "defaultChatModeHint": "This mode will be automatically selected when the page refreshes", "defaultChatModeChanged": "Default chat mode updated", "agnoToolSelectorTitle": "Agno Tool Selector", "agnoToolSelectorLabel": "Show Tool Selection Button", "agnoToolSelectorHint": "When enabled, a tool selection button will appear in Agno mode", "agnoToolSelectorEnabled": "Tool selector enabled", "agnoToolSelectorDisabled": "Tool selector disabled", "audioSettings": "Audio Recording Settings", "enable24x7Recording": "Auto Start Recording", "enable24x7RecordingDesc": "When enabled, recording will automatically start when opening the app", "audioAsrConfig": "Audio Recognition (ASR) Configuration", "currentVersion": "Current Version" }, "costTracking": { "title": "Cost Tracking", "subtitle": "View LLM usage and cost statistics", "statisticsPeriod": "Statistics Period", "last7Days": "Last 7 Days", "last30Days": "Last 30 Days", "last90Days": "Last 90 Days", "refresh": "Refresh", "totalCost": "Total Cost", "totalTokens": "Total Tokens", "totalRequests": "Total Requests", "featureCostDetails": "Feature Cost Details", "feature": "Feature", "featureId": "ID", "inputTokens": "Input Tokens", "outputTokens": "Output Tokens", "requests": "Requests", "cost": "Cost", "modelCostDetails": "Model Cost Details", "model": "Model", "inputCost": "Input Cost", "outputCost": "Output Cost", "totalCostLabel": "Total Cost", "dailyCostTrend": "Daily Cost Trend", "loadFailed": "Load failed", "featureNames": { "event_assistant": "Event Assistant", "event_summary": "Event Summary", "project_assistant": "Project Assistant", "job_task_context_mapper": "Task Context Mapping", "job_task_summary": "Task Summary Generation", "task_summary": "Task Summary (Manual)", "activity_summary": "Activity Summary", "vision_assistant": "Auto Todo Detection", "workspace_assistant": "Workspace Assistant", "plan_assistant": "Plan Assistant", "unknown": "Unknown Feature" } } }, "journalPanel": { "panelTitle": "Journal", "panelSubtitle": "Write freely and link today's todos and activities.", "historyTitle": "History", "historyLoading": "Loading...", "historyEmpty": "No entries yet", "untitled": "Untitled", "titleLabel": "Title", "titlePlaceholder": "Optional title", "dateLabel": "Date", "moodLabel": "Mood", "moodPlaceholder": "Select mood", "moodCalm": "Calm", "moodFocused": "Focused", "moodTired": "Tired", "moodEnergized": "Energized", "moodAnxious": "Anxious", "energyLabel": "Energy", "energyPlaceholder": "Select energy", "energyLow": "Low", "energyMid": "Medium", "energyHigh": "High", "tagsLabel": "Tags", "tagsPlaceholder": "Comma-separated", "relatedTodosLabel": "Related Todos", "relatedTodosPlaceholder": "Todo ID", "relatedActivitiesLabel": "Related Activities", "relatedActivitiesPlaceholder": "Activity ID", "settingsTitle": "Daily Reset", "refreshModeLabel": "Refresh mode", "refreshModeFixed": "Fixed time", "refreshModeWorkHours": "Work hours", "refreshModeCustom": "Custom time", "fixedTimeLabel": "Fixed", "workHoursLabel": "Work hours", "customTimeLabel": "Custom", "autoLinkToggle": "Auto-link after save", "autoObjectiveToggle": "Auto-generate objective log", "autoAiToggle": "Auto-generate AI view", "tabOriginal": "Original", "tabObjective": "Objective", "tabAi": "AI View", "save": "Save", "saving": "Saving...", "jumpToToday": "Today", "generateObjective": "Generate Objective", "generatingObjective": "Generating...", "generateAi": "Generate AI View", "generatingAi": "Generating...", "autoLink": "Auto Link", "autoLinking": "Linking...", "copyToOriginal": "Copy to original", "contentPlaceholder": "Write freely...", "objectivePlaceholder": "Generate the objective log", "aiPlaceholder": "Generate AI view", "saveSuccess": "Saved", "saveFailed": "Save failed", "autoLinkSuccess": "Linked {todoCount} todos, {activityCount} activities", "autoLinkFailed": "Auto-link failed", "generateFailed": "Generation failed", "loadFailed": "Load failed: {error}" }, "bottomDock": { "calendar": "Calendar", "activity": "Activity", "todos": "Todos", "chat": "Chat", "todoDetail": "Todo Detail", "diary": "Diary", "settings": "Settings", "costTracking": "Cost", "achievements": "Achievements", "screenshots": "Screenshots", "debugShots": "Debug Shots", "audio": "Audio", "unassigned": "Unassigned" }, "panelMenu": { "moreActions": "Panel actions", "switchPanel": "Switch panel", "closePanel": "Close panel", "pinPanel": "Pin panel", "unpinPanel": "Unpin panel", "openInNewWindow": "Open in new window", "pinnedBadge": "Pinned" }, "todoExtraction": { "extractButton": "Extract Todos", "extracting": "Extracting todos...", "extractSuccess": "Extracted {count} todos", "extractFailed": "Failed to extract todos: {error}", "noTodosFound": "No todos found", "notWhitelistApp": "This app does not support todo extraction", "modalTitle": "Confirm Todos", "modalDescription": "Please select todos to add to your list:", "selectAll": "Select All", "deselectAll": "Deselect All", "confirmAdd": "Confirm Add ({count})", "cancel": "Cancel", "todoTitle": "Title", "todoDescription": "Description", "todoTime": "Time", "todoSource": "Source", "todoScheduledTime": "Scheduled Time", "source": "Source", "time": "Time", "eventId": "Event ID", "addSuccess": "Successfully added {count} todos", "addFailed": "Failed to add todos: {error}", "selectedCount": "Selected {count} items", "newDraftTodoNotification": "New Todo Pending Confirmation", "accept": "Accept", "reject": "Reject", "accepting": "Processing...", "rejecting": "Processing...", "acceptSuccess": "Added to todo list", "rejectSuccess": "Rejected", "acceptFailed": "Failed: {error}", "rejectFailed": "Failed: {error}", "autoExtracted": "Auto Extracted", "noTimeSpecified": "No time specified", "failedItems": "{count} items failed", "newNotification": "New notification", "collapseNotification": "Collapse notification", "expandNotification": "Expand notification", "closeNotification": "Close notification", "justNow": "just now", "minutesAgo": "{count}m ago", "hoursAgo": "{count}h ago", "daysAgo": "{count}d ago", "dateFormat": "{month}/{day}", "llmConfigMissing": "Please configure AI service", "llmConfigMissingHint": "Click to set API Key" }, "audio": { "extractionSummary": "Extracted {todoCount} todos and {scheduleCount} schedules", "linkTodo": "Link to todos", "extractionModalTitle": "Review extracted items", "extractionModalDesc": "Select todos/schedules to add into your todo list:", "todoSection": "Todos ({count})", "scheduleSection": "Schedules ({count})", "scheduleTag": "Schedule", "linkTodoTag": "Extracted", "scheduleFallbackTitle": "Schedule", "noExtractions": "No extracted items to add", "selectedCount": "Selected {count} items", "confirmAdd": "Add selected ({count})", "addSuccess": "Added {count} items", "addFailed": "Failed to add {count} items" }, "chat": { "greetings": { "title": "Free Todo, Just Do It", "subtitle": "Your intelligent todo assistant for breaking down tasks, prioritizing, and getting things done" }, "planModeInputPlaceholder": "e.g. Help me plan the todos for moving this weekend", "difyTest": { "inputPlaceholder": "Prompt for Dify test channel..." }, "aiThinking": "AI is thinking...", "generatingQuestions": "AI is generating questions...", "generateQuestionsFailed": "Failed to generate questions, please try again", "generateSummaryFailed": "Failed to generate summary, please try again", "loading": "Loading", "suggestions": { "breakdown": "Break Down Task", "breakdownPrompt": "Help me break down this task", "plan": "Create Plan", "planPrompt": "Help me create a detailed plan", "priority": "Prioritize", "priorityPrompt": "Help me prioritize these tasks", "time": "Time Management", "timePrompt": "Give me some time management advice", "review": "Task Review", "reviewPrompt": "Help me review recent task completion", "optimize": "Optimize Workflow", "optimizePrompt": "How can I optimize my workflow?", "goal": "Set Goals", "goalPrompt": "Help me set some goals", "advice": "Get Advice", "advicePrompt": "Give me some suggestions for the current to-do" }, "assistant": "Assistant", "user": "You", "chatMode": "Chat mode", "toggleMode": "Toggle Ask/Plan/Edit mode", "mentionFileOrTodo": "Mention a file or todo", "send": "Send", "stop": "Stop", "linkedTodos": "Linked todos ({count})", "linkedTodosEn": "Linked todos ({count})", "collapse": "Collapse", "expand": "Expand", "clearSelection": "Clear selection", "generatingQuestion": "Generating question {count}", "answerQuestions": "Please answer the following questions to complete task details", "answerQuestionsDesc": "Your answers will help AI better understand task requirements and generate a detailed plan", "multipleChoice": "(Multiple choice)", "customAnswer": "Custom answer", "customAnswerPlaceholder": "Enter your custom answer...", "submitting": "Submitting...", "submitAnswer": "Submit answer", "answeredProgress": "Answered {answered}/{total} questions", "generatingSummary": "AI is generating summary...", "generatingSummaryDesc": "Please wait, AI is generating a detailed task summary and subtask list based on your answers", "generating": "Generating...", "parsingContent": "Parsing full content...", "noTodoContext": "No todo context", "userInput": "User input", "loadHistoryFailed": "Failed to load history", "noTodosAvailable": "No todos available; chat context is empty.", "noTodosFound": "No todos found", "extractModalDescription": "Please select the todos to add:", "selectedCount": "Selected {count} items", "confirmAdd": "Confirm Add ({count})", "applying": "Applying...", "selectedTodo": "[Selected Todo]", "rootParentTodo": "[Root Parent Todo]", "allSubTodos": "[All Sub-todos] (total {count})", "allSubTodosRoot": "[All Sub-todos] (total {count})", "todoContextHeader": "{source} (total {count}):", "todoContextHeaderEn": "{source} (total {count}):", "noPlanJsonFound": "No todo JSON found; no tasks created.", "parsedNoValidTodos": "Parsed but no valid todos found.", "parsePlanJsonFailed": "Failed to parse plan JSON; no tasks created.", "noResponseReceived": "No response received, please try again.", "addedTodos": "Added {count} todos to the list.", "errorOccurred": "Something went wrong. Please try again.", "breakdownSummary": { "title": "Task Breakdown Result", "description": "Based on your answers, we have broken down the task into the following subtasks:", "taskSummary": "Task Summary", "subtaskList": "Subtask List", "noSubtasks": "No subtasks", "applying": "Applying...", "acceptAndApply": "Accept and Apply" }, "planSummary": { "title": "Todo Planning Result", "description": "Please review the AI-generated todo summary and subtask list, then click Accept to confirm", "taskSummary": "Todo Summary", "subtaskList": "Sub-todo List", "noSubtasks": "No sub-todos generated", "applying": "Applying...", "acceptAndApply": "Accept & Apply" }, "editMode": { "inputPlaceholder": "Describe what content you want to generate...", "appendTo": "Append to", "appendSuccess": "Appended to todo notes", "appendFailed": "Failed to append", "selectTodo": "Select todo", "noLinkedTodos": "Please link todos first", "append": "Append", "appending": "Appending", "appended": "Appended", "failed": "Failed", "aiRecommended": "AI" }, "modes": { "ask": { "label": "Ask", "description": "Chat freely" }, "plan": { "label": "Plan", "description": "Break down and add todos" }, "edit": { "label": "Edit", "description": "Generate and append to notes" }, "difyTest": { "label": "Dify Test", "description": "Use Dify API test channel for replies" }, "agno": { "label": "Agno", "description": "Intelligent Agent based on Agno framework" }, "active": "Active" }, "webSearch": { "label": "Web Search", "toggle": "Toggle web search", "enabled": "Web search enabled", "disabled": "Web search disabled" }, "toolSelector": { "label": "Tools", "title": "Select Tools", "selectAll": "Select All", "deselectAll": "Deselect All", "selectedCount": "{count} tools selected", "allSelected": "All tools enabled", "noneSelected": "No tools selected", "externalTools": "External Tools", "freetodoTools": "Todo Tools", "categories": { "todo": "Todo Management", "breakdown": "Task Breakdown", "time": "Time Parsing", "conflict": "Conflict Detection", "stats": "Statistics", "tags": "Tag Management", "search": "Web Search" }, "externalCategories": { "search": "Search", "local": "Local" } }, "sources": "Sources", "todoContext": { "id": "ID", "name": "Name", "description": "Description", "notes": "Notes", "deadline": "Time", "priority": "Priority", "status": "Status", "tags": "Tags", "parentTodoId": "Parent Todo ID", "parentName": "Parent Name", "due": "Time: {deadline}", "tagsLabel": "Tags: {tags}" }, "toolCall": { "calling": "Calling {tool}...", "completed": "{tool} completed", "failed": "{tool} failed", "result": "Result", "tools": { "create_todo": "Create Todo", "complete_todo": "Complete Todo", "update_todo": "Update Todo", "list_todos": "List Todos", "search_todos": "Search Todos", "delete_todo": "Delete Todo", "breakdown_task": "Task Breakdown", "parse_time": "Time Parse", "check_schedule_conflict": "Conflict Check", "get_todo_stats": "Statistics", "get_overdue_todos": "Overdue Todos", "list_tags": "List Tags", "get_todos_by_tag": "Get by Tag", "suggest_tags": "Suggest Tags", "web_search": "Web Search", "search_news": "News Search", "duckduckgo": "DuckDuckGo Search", "websearch": "Web Search", "hackernews": "Hacker News", "file": "File Operations", "local_fs": "File Write", "shell": "Shell Command", "sleep": "Sleep", "unknown": "Unknown Tool" } } }, "calendar": { "title": "Calendar", "monthView": "Month", "weekView": "Week", "dayView": "Day", "today": "Today", "previous": "Previous", "next": "Next", "create": "Create", "yearMonth": "{year}/{month}", "yearMonthWeek": "{year}/{month} (Week {week})", "yearMonthDay": "{year}/{month}/{day}", "createOnDate": "Create todo on {date}", "closeCreate": "Close", "inputTodoTitle": "Enter todo title...", "noTodosDue": "No todos due, click below to create one", "allDay": "All day", "startTime": "Start", "endTime": "End", "floating": "Floating", "floatingEmpty": "No floating todos", "workingHours": "Working hours", "weekdays": { "monday": "Mon", "tuesday": "Tue", "wednesday": "Wed", "thursday": "Thu", "friday": "Fri", "saturday": "Sat", "sunday": "Sun" }, "weekPrefix": "" }, "reminder": { "label": "Reminder", "noReminder": "No reminder", "atTime": "At time", "minutesBefore": "{count} min before", "hoursBefore": "{count} hr before", "daysBefore": "{count} day before", "custom": "Custom", "add": "Add", "clear": "Clear", "needsDeadline": "Reminders trigger relative to the deadline.", "needsSchedule": "Reminders trigger relative to the scheduled time.", "unit": { "minutes": "minutes", "hours": "hours", "days": "days" } }, "datePicker": { "dateTab": "Date", "rangeTab": "Time range", "dateLabel": "Date", "timeLabel": "Time", "pickDate": "Pick a date", "allDay": "All day", "repeatLabel": "Repeat", "repeatNone": "Does not repeat", "repeatDaily": "Daily", "repeatWeekly": "Weekly", "repeatMonthly": "Monthly", "repeatYearly": "Yearly", "rangeLabel": "Time range", "startLabel": "Start", "endLabel": "End", "timeZoneLabel": "Time zone", "time": "Time", "timeRange": "Time range", "startTime": "Start", "endTime": "End", "clear": "Clear", "confirm": "Confirm" }, "layoutSelector": { "label": "Layout", "selectLayout": "Select layout", "customLayout": "Custom Layout", "saveCurrentLayout": "Save current layout", "layoutActions": "Layout actions", "saveLayoutTitle": "Save layout", "saveLayoutDescription": "Name the current layout", "saveLayoutPlaceholder": "Enter a layout name", "renameLayout": "Rename", "renameLayoutTitle": "Rename layout", "renameLayoutDescription": "Set a new name for this layout", "renameLayoutPlaceholder": "Enter a new name", "deleteLayout": "Delete", "deleteLayoutTitle": "Delete layout", "deleteLayoutDescription": "Delete \"{name}\"? This action cannot be undone.", "overwriteConfirmTitle": "Overwrite existing layout", "overwriteConfirmDescription": "A layout named \"{name}\" already exists. Overwrite it?", "layoutNameRequired": "Layout name is required", "confirm": "Confirm", "cancel": "Cancel", "close": "Close", "layouts": { "default": "Todo List Mode", "calendar": "Todo Calendar Mode", "lifetrace": "LifeTrace Mode" } }, "scheduler": { "title": "Scheduler Management", "description": "Manage background scheduled jobs and their intervals", "running": "Running", "paused": "Paused", "schedulerRunning": "Scheduler Running", "schedulerStopped": "Scheduler Stopped", "runningCount": "{running} running / {paused} paused", "refresh": "Refresh", "pauseAll": "Pause All", "resumeAll": "Resume All", "pause": "Pause", "resume": "Resume", "interval": "Interval", "next": "Next", "hour": "h", "minute": "m", "second": "s", "save": "Save", "cancel": "Cancel", "editInterval": "Edit interval", "noJobs": "No scheduled jobs", "loading": "Loading...", "legacyJobs": "Legacy Jobs", "legacyNotNeeded": "Not needed in this frontend", "intervalCannotBeZero": "Interval cannot be 0", "jobPaused": "Job {job} paused", "jobResumed": "Job {job} resumed", "pauseFailed": "Pause failed: {error}", "resumeFailed": "Resume failed: {error}", "allJobsPaused": "All jobs paused", "allJobsResumed": "All jobs resumed", "intervalUpdated": "Job {job} interval updated", "updateFailed": "Update failed: {error}", "dateLocale": "en-US", "jobs": { "recorder_job": "Screen Recorder", "ocr_job": "OCR Processing", "task_context_mapper_job": "Task Context Mapper", "task_summary_job": "Task Summary", "clean_data_job": "Data Cleanup", "activity_aggregator_job": "Activity Aggregator", "deadline_reminder_job": "DDL Reminder", "todo_recorder_job": "Screen Recorder (Todo)", "proactive_ocr_job": "Proactive OCR", "audio_recording_job": "Audio Recording" }, "jobDescriptions": { "recorder_job": "Capture screenshots periodically", "ocr_job": "Extract text from screenshots", "task_context_mapper_job": "(Legacy) Associate screenshots with task context", "task_summary_job": "(Legacy) Generate task execution summary", "clean_data_job": "Clean up expired screenshots and data", "activity_aggregator_job": "Aggregate user activity events", "deadline_reminder_job": "Check todo deadlines and send notifications based on reminder settings", "todo_recorder_job": "Only capture screenshots from whitelisted apps for auto todo detection", "proactive_ocr_job": "Automatically detect and process WeChat/Feishu windows for OCR (Windows only)", "audio_recording_job": "7x24 continuous audio recording with real-time transcription and information extraction" } }, "common": { "priority": { "high": "High", "medium": "Medium", "low": "Low", "none": "None" }, "status": { "active": "Active", "completed": "Completed", "canceled": "Canceled", "draft": "Draft" }, "numberLocale": "en-US" }, "debugCapture": { "title": "Screenshot Management (Debug)", "screenshotDetail": "Screenshot Details", "close": "Close", "loading": "Loading...", "loadFailed": "Load Failed", "imageLoadFailed": "Image Load Failed", "screenshotId": "Screenshot ID", "screenshot": "Screenshot", "previous": "Previous", "next": "Next", "details": "Details", "time": "Time", "app": "App", "unknown": "Unknown", "windowTitle": "Window Title", "none": "None", "size": "Size", "ocrResult": "OCR Result", "selectedEvents": "Selected {count} events", "aggregating": "Aggregating...", "aggregateActivity": "Aggregate as Activity ({count})", "clearSelection": "Clear Selection", "startDate": "Start Date", "endDate": "End Date", "appName": "App Name", "appNamePlaceholder": "App name", "search": "Search", "eventTimeline": "Event Timeline", "foundEvents": "Found {total} events", "loadedEvents": "({loaded} loaded)", "loadingMore": "Loading more...", "scrollToLoadMore": "Scroll to load more", "allEventsLoaded": "All events loaded", "noEventsFound": "No events found", "adjustSearchCriteria": "Please adjust search criteria", "eventsCount": "{count} events", "select": "Select", "deselect": "Deselect", "unknownWindow": "Unknown Window", "duration": "Duration {duration}", "inProgress": "In Progress", "noDescription": "No description", "screenshotCount": "{count} screenshots", "selectEventsPrompt": "Please select events to aggregate first", "unendedEventsError": "Selected events contain unended events, cannot aggregate", "activityCreated": "Successfully created activity: {title}\nContains {count} events", "activity": "Activity", "aggregateFailed": "Failed to aggregate events, please try again later", "extractFailed": "Failed to extract todos, please try again later" }, "achievements": { "title": "Achievement System", "placeholder": "Placeholder: plug an achievements component here", "achievement1": { "name": "First Steps", "description": "Complete first todo" }, "achievement2": { "name": "Todo Master", "description": "Complete 10 todos" }, "achievement3": { "name": "Efficiency Star", "description": "Complete tasks for 7 consecutive days" }, "achievement4": { "name": "Perfectionist", "description": "Complete 100 tasks" } }, "todoList": { "addTodo": "Add todo", "add": "Add", "submit": "Submit", "reset": "Reset", "searchPlaceholder": "Search todos...", "loadFailed": "Load failed: {error}", "noTodos": "No todos", "deadline": "Date", "description": "Description", "descriptionPlaceholder": "Describe task details...", "tags": "Tags (comma-separated)", "tagsPlaceholder": "e.g., work, report", "priority": "Priority", "notes": "Notes", "notesPlaceholder": "Personal notes or action items", "filter": "Filter", "filterStatus": "Status", "filterTag": "Tag", "filterDueTime": "Time", "filterAll": "All", "statusActive": "Active", "statusCompleted": "Completed", "statusCanceled": "Canceled", "statusDraft": "Draft", "dueTimeOverdue": "Overdue", "dueTimeToday": "Today", "dueTimeTomorrow": "Tomorrow", "dueTimeThisWeek": "This Week", "dueTimeThisMonth": "This Month", "dueTimeFuture": "Future", "clearFilters": "Clear Filters", "quickFilters": "Quick Filters", "moreOptions": "More Options" }, "todoDetail": { "editTitle": "Click to edit title", "viewDescription": "View description", "selectTodoPrompt": "Please select a todo to view details", "detailViewLabel": "Detail", "artifactsViewLabel": "Artifacts", "markAsComplete": "Mark as complete", "delete": "Delete", "addDeadline": "Add date", "addTags": "Add tags", "tagsPlaceholder": "Separate multiple tags with commas", "save": "Save", "cancel": "Cancel", "clear": "Clear", "current": "Current", "priorityLabel": "Priority: {priority}", "setAsChild": "Set as child task", "collapseSubTasks": "Collapse sub-tasks", "expandSubTasks": "Expand sub-tasks", "addChildPlaceholder": "Enter child todo name...", "add": "Add", "addChild": "Add child todo", "childTodos": "Child Todos", "useAiPlan": "AI Breakdown", "useAiPlanTitle": "Break down task with AI", "getAdvice": "Get Advice", "getAdviceTitle": "Get AI advice for this task", "descriptionLabel": "Description", "backgroundLabel": "Background", "notesLabel": "Notes", "descriptionEmptyPlaceholder": "No description (click to add)", "backgroundPlaceholder": "Add background context...", "backgroundEmptyPlaceholder": "No background (click to add)", "notesPlaceholder": "Insert your notes here", "notesEmptyPlaceholder": "No notes yet", "progressLabel": "Progress", "progressEmptyTitle": "No workbench steps yet", "progressEmptyHint": "Progress will appear here when workbench tasks run.", "artifactsLabel": "Artifacts", "artifactsEmpty": "No artifacts yet", "contextLabel": "Context", "editContext": "Edit detail", "contextAttachmentsLabel": "Context attachments", "contextAttachmentsEmpty": "No attachments yet", "uploadLabel": "Upload", "uploadHint": "Max 50MB per file", "previewLabel": "Preview", "previewUnavailable": "Preview not available for this file", "downloadLabel": "Download", "uploadFailed": "Upload failed", "removeAttachmentFailed": "Failed to remove attachment", "uploadSizeLimit": "Single file exceeds 50MB", "deleteConfirmTitle": "Delete this todo?", "deleteConfirmDescription": "This action cannot be undone.", "deleteConfirmWithChildren": "This will delete this todo and {count} child todos.", "deleteConfirmCancel": "Cancel", "deleteConfirmDelete": "Delete" }, "automationTasks": { "title": "Automation tasks", "description": "Create lightweight scheduled jobs like fetching content at a specific time.", "createTitle": "New task", "createHint": "Only web fetch is supported for now", "empty": "No automation tasks yet.", "dateLocale": "en-US", "labels": { "name": "Task name", "description": "Description", "url": "Fetch URL", "method": "Method", "enabled": "Enabled", "enabledOn": "Enabled", "enabledOff": "Disabled", "scheduleType": "Schedule type", "intervalMinutes": "Every (minutes)", "cron": "Cron expression", "runAt": "Run at", "lastRun": "Last run: {time}", "status": "Status" }, "placeholders": { "name": "Daily check-in", "description": "Optional notes" }, "scheduleType": { "interval": "Interval", "cron": "Cron", "once": "Once" }, "scheduleSummary": { "interval": "Every {minutes} min", "cron": "Cron: {cron}", "once": "Once at {time}" }, "actions": { "create": "Create task", "run": "Run now", "enable": "Enable", "disable": "Disable", "delete": "Delete" }, "status": { "success": "Success", "error": "Error", "never": "Never" }, "errors": { "nameRequired": "Task name is required", "urlRequired": "URL is required", "intervalRequired": "Interval minutes must be greater than 0", "cronRequired": "Cron expression is required", "runAtRequired": "Run time is required", "createFailed": "Failed to create task: {error}", "runFailed": "Failed to run task: {error}", "updateFailed": "Failed to update task: {error}", "deleteFailed": "Failed to delete task: {error}" }, "messages": { "created": "Task created", "ran": "Task executed", "enabled": "Task enabled", "disabled": "Task disabled", "deleted": "Task deleted" }, "confirmDelete": "Delete this task?" }, "contextMenu": { "selectedCount": "Selected {count} items", "batchCancel": "Batch Cancel", "batchDelete": "Batch Delete", "addChild": "Add child todo", "useAiPlan": "Use AI to plan", "cancel": "Cancel", "delete": "Delete", "childNamePlaceholder": "Enter child todo name...", "cancelButton": "Cancel", "addButton": "Add", "extractButton": "Extract Todos", "extracting": "Extracting todos..." }, "onboarding": { "welcomeTitle": "Welcome to Free Todo", "welcomeDescription": "Let's quickly set up your AI assistant.", "apiKeyStepTitle": "API Key", "apiKeyStepDescription": "Enter your Aliyun Bailian API Key.", "dockTriggerTitle": "Move Mouse Down", "dockTriggerDescription": "Move your mouse to the bottom edge to reveal the Dock.", "dockStepTitle": "Bottom Dock", "dockStepDescription": "Click to toggle panels, drag to reorder.", "dockRightClickTitle": "Try Right-Click", "dockRightClickDescription": "Right-click on this item to see more options.", "dockMenuTitle": "Panel Selector", "dockMenuDescription": "Select a panel to switch. Try clicking \"Chat\".", "completeTitle": "All Set!", "completeDescription": "Start managing your tasks with AI assistance.", "nextBtn": "Next", "prevBtn": "Previous", "doneBtn": "Get Started", "skipBtn": "Skip Tour", "restartTour": "Restart Tour", "restartTourDescription": "Click this button to start or restart the onboarding tour anytime" } } ================================================ FILE: free-todo-frontend/lib/i18n/messages/zh.json ================================================ { "language": { "zh": "中文", "en": "English" }, "colorTheme": { "catppuccin": "Catppuccin", "blue": "蓝色", "neutral": "中性", "label": "配色风格" }, "theme": { "light": "浅色", "dark": "深色", "system": "跟随系统" }, "layout": { "currentLanguage": "当前语言", "currentTheme": "当前主题", "userSettings": "用户设置", "openSettings": "打开设置" }, "page": { "title": "Free Todo Canvas", "subtitle": "日历视图与待办视图并列排布,可通过底部 Dock 快速切换与组合,并支持拖拽调整宽度。", "calendarLabel": "日历视图", "calendarPlaceholder": "占位:在这里接入日历组件", "activityLabel": "活动流", "activityPlaceholder": "占位:在这里接入活动流仪表盘", "todosLabel": "待办视图", "todosPlaceholder": "占位:在这里接入 Todo 待办", "todoListTitle": "待办", "chatLabel": "AI 聊天", "chatPlaceholder": "占位:在这里接入 AI 聊天组件", "chatTitle": "Free Todo - AI 智能助手", "chatSubtitle": "一个个性化的 AI 聊天应用,帮助您管理待办事项、提高效率。", "chatQuestion": "今天我能为您做些什么?", "chatSuggestion1": "拆解并排序今天的待办", "chatSuggestion2": "结合日历规划一周安排", "chatSuggestion3": "总结项目任务并给出下一步", "chatInputPlaceholder": "在此输入你的想法", "chatSendButton": "发送", "chatHistory": "历史记录", "newChat": "新建对话", "recentSessions": "最近会话", "noHistory": "暂无历史记录", "messagesCount": "{count} 条消息", "loadHistoryFailed": "加载历史记录失败", "loadSessionFailed": "加载会话失败", "sessionLoaded": "已加载历史会话", "todoDetailLabel": "待办详情", "todoDetailPlaceholder": "占位:在这里接入待办详情组件", "diaryLabel": "日记", "diaryPlaceholder": "占位:在这里接入日记组件", "settingsLabel": "设置", "settingsPlaceholder": "占位:在这里接入设置组件", "costTrackingLabel": "费用统计", "costTrackingPlaceholder": "占位:在这里查看费用统计", "achievementsLabel": "成就", "achievementsPlaceholder": "占位:在这里接入成就组件", "screenshotsLabel": "截图管理(调试)", "screenshotsPlaceholder": "事件时间轴与截图展示(仅开发模式可见)", "debugShotsLabel": "截图管理(调试)", "debugShotsPlaceholder": "占位:用于截图采集/管理的调试面板(仅开发模式可见)", "audioLabel": "音频录制", "audioPlaceholder": "占位:在这里接入音频录制组件", "backendUnavailableBadge": "后端不可用", "backendUnavailableTooltip": "后端模块未启用或缺少依赖。", "backendUnavailableTitle": "功能不可用", "backendUnavailableDescription": "后端模块未启用或缺少依赖。", "audioRecording": "录音中", "audioRecordingStopped": "已停止", "audioStandby": "待机中", "audioStartRecording": "开始录音", "audioStopRecording": "停止录音", "audioRecordingStatus": "录音状态", "settings": { "categoryWorkspaceTitle": "工作区与面板", "categoryWorkspaceDescription": "调整底部 Dock 的显示方式与面板开关。", "categoryAutomationTitle": "自动化", "categoryAutomationDescription": "管理自动待办检测等自动化能力。", "categoryAiTitle": "AI 与集成", "categoryAiDescription": "配置大模型与联网搜索等 AI 服务。", "categoryDeveloperTitle": "开发者与高级", "categoryDeveloperDescription": "高级与实验性配置(录制、音频、调度)。", "categoryHelpTitle": "引导与关于", "categoryHelpDescription": "新手引导、版本信息与帮助入口。", "searchPlaceholder": "搜索设置...", "searchNoResultsTitle": "未找到匹配的设置", "searchNoResultsHint": "试试其他关键词。", "aboutTitle": "版本与信息", "aboutDescription": "查看当前版本号与构建信息。", "journalSettingsTitle": "日记设置", "journalSettingsDescription": "配置日记的每日刷新点与自动生成策略。", "journalRefreshModeLabel": "刷新模式", "journalRefreshModeFixed": "固定时间", "journalRefreshModeWorkHours": "工作时段", "journalRefreshModeCustom": "自定义", "journalFixedTimeLabel": "固定时间", "journalWorkHoursLabel": "工作时段", "journalCustomTimeLabel": "自定义时间", "journalAutoLinkLabel": "保存后自动关联", "journalAutoObjectiveLabel": "自动生成客观记录", "journalAutoAiLabel": "自动生成 AI 视角", "autoTodoDetectionTitle": "自动待办检测", "autoTodoDetectionDescription": "自动从白名单应用截图中检测待办事项,并创建为待确认的草稿待办", "autoTodoDetectionLabel": "启用自动待办检测", "autoTodoDetectionHint": "已启用:系统将自动检测白名单应用中的待办事项", "autoTodoDetectionEnabled": "已启用自动待办检测", "autoTodoDetectionDisabled": "已关闭自动待办检测", "whitelistApps": "应用白名单", "whitelistAppsPlaceholder": "输入应用名称后按回车添加", "whitelistAppsDesc": "只有这些应用的截图才会触发自动待办检测", "loadFailed": "加载配置失败:{error}", "saveFailed": "保存配置失败:{error}", "saveSuccess": "保存成功", "costTrackingPanelTitle": "费用面板", "costTrackingPanelDescription": "控制是否在面板中展示费用统计功能", "costTrackingPanelLabel": "启用费用统计面板", "costTrackingPanelHint": "关闭后费用统计不会显示在面板选择器中", "costTrackingPanelEnabled": "已启用费用统计面板", "costTrackingPanelDisabled": "已关闭费用统计面板", "panelSwitchesTitle": "面板开关", "panelSwitchesDescription": "控制各个面板的显示与隐藏", "panelEnabled": "已启用", "panelDisabled": "已禁用", "llmConfig": "LLM 配置", "difyConfigTitle": "Dify 测试配置", "difyEnabledLabel": "启用 Dify 测试模式", "difyEnabledDescription": "开启后,聊天面板中的 Dify Test 模式将通过 Dify API 返回回复。", "difySaveSuccess": "Dify 配置已保存", "tavilyConfigTitle": "Tavily 联网搜索配置", "tavilySaveSuccess": "Tavily 配置已保存", "tavilyApiKeyHint": "如何获取 API Key?请访问", "tavilyApiKeyLink": "Tavily 控制台", "save": "保存", "apiKey": "API Key", "baseUrl": "Base URL", "model": "模型", "temperature": "Temperature", "maxTokens": "Max Tokens", "testConnection": "测试 API 连接", "testSuccess": "✓ API 配置验证成功!", "testFailed": "✗ API 配置验证失败", "apiKeyRequired": "请先填写 API Key 和 Base URL", "apiKeyHint": "如何获取 API Key?请访问", "apiKeyLink": "阿里云百炼控制台", "basicSettings": "屏幕录制设置", "enableRecording": "启用录制", "enableRecordingDesc": "开启屏幕录制和截图功能", "screenshotInterval": "截图间隔(秒)", "enableBlacklist": "启用黑名单(用于截图调试面板和活动面板)", "enableBlacklistDesc": "开启后可以设置不需要截图的应用", "appBlacklist": "应用黑名单", "blacklistPlaceholder": "输入应用名称后按回车添加", "blacklistDesc": "这些应用的窗口将不会被截图记录", "dockDisplayModeTitle": "底部 Dock 显示模式", "dockDisplayModeDescription": "控制底部 Dock 显示行为", "dockDisplayModeLabel": "显示模式", "dockDisplayModeFixed": "固定显示", "dockDisplayModeAutoHide": "自动隐藏", "dockDisplayModeChanged": "底部 Dock 显示模式已更改", "notificationPermissionTitle": "通知权限", "notificationPermissionDescription": "手动触发系统通知权限申请(浏览器与 Tauri)。", "notificationPermissionStatusLabel": "当前权限", "notificationPermissionStatusGranted": "已授权", "notificationPermissionStatusDenied": "已拒绝", "notificationPermissionStatusDefault": "未授权", "notificationPermissionStatusUnknown": "未知", "notificationPermissionStatusNotRequired": "无需授权", "notificationPermissionRequest": "请求通知权限", "notificationPermissionRequesting": "正在请求...", "notificationPermissionRequestSuccess": "通知权限已授予", "notificationPermissionRequestDenied": "通知权限被拒绝", "notificationPermissionRequestFailed": "请求通知权限失败:{error}", "notificationPermissionNotSupported": "当前环境不支持通知权限", "notificationPermissionHint": "授权后可在系统通知栏显示提醒。", "notificationPermissionElectronHint": "Electron 会在首次通知时自动请求权限。", "developerSectionTitle": "开发者选项", "developerSectionDescription": "高级和实验性功能配置,仅推荐开发者或高级用户使用。", "devPanelsTitle": "开发中的面板", "devPanelsDescription": "这些面板仍在开发中,默认不会显示在底部 Dock 中,你可以在这里手动开启。", "modeSwitcherTitle": "聊天模式切换器", "modeSwitcherDescription": "控制聊天面板是否显示 Ask/Plan/Edit 等模式切换选项", "modeSwitcherLabel": "显示模式切换器", "modeSwitcherHint": "关闭后,聊天面板将只使用 Ask 模式", "modeSwitcherEnabled": "已启用模式切换器", "modeSwitcherDisabled": "已关闭模式切换器", "defaultChatModeLabel": "默认聊天模式", "defaultChatModeHint": "页面刷新时将自动进入此模式", "defaultChatModeChanged": "默认聊天模式已更新", "agnoToolSelectorTitle": "Agno 工具选择器", "agnoToolSelectorLabel": "显示工具选择按钮", "agnoToolSelectorHint": "开启后,在 Agno 模式下会显示工具选择按钮", "agnoToolSelectorEnabled": "已启用工具选择器", "agnoToolSelectorDisabled": "已关闭工具选择器", "audioSettings": "音频录制设置", "enable24x7Recording": "自动启动录音", "enable24x7RecordingDesc": "启用后,打开应用时将自动开始录音", "audioAsrConfig": "音频识别(ASR)配置", "currentVersion": "当前版本" }, "costTracking": { "title": "费用统计", "subtitle": "查看 LLM 使用情况和费用统计", "statisticsPeriod": "统计周期", "last7Days": "最近 7 天", "last30Days": "最近 30 天", "last90Days": "最近 90 天", "refresh": "刷新", "totalCost": "总费用", "totalTokens": "总 Token 数", "totalRequests": "总请求数", "featureCostDetails": "功能费用明细", "feature": "功能", "featureId": "ID", "inputTokens": "输入 Token", "outputTokens": "输出 Token", "requests": "请求数", "cost": "费用", "modelCostDetails": "模型费用明细", "model": "模型", "inputCost": "输入费用", "outputCost": "输出费用", "totalCostLabel": "总费用", "dailyCostTrend": "每日费用趋势", "loadFailed": "加载失败", "featureNames": { "event_assistant": "事件助手", "event_summary": "事件摘要", "project_assistant": "项目助手", "job_task_context_mapper": "任务上下文映射", "job_task_summary": "任务摘要生成", "task_summary": "任务摘要生成(手动)", "activity_summary": "活动总结", "vision_assistant": "自动待办检测", "workspace_assistant": "工作区助手", "plan_assistant": "计划助手", "unknown": "未知功能" } } }, "journalPanel": { "panelTitle": "日记", "panelSubtitle": "自由记录,并自动关联今日待办与活动。", "historyTitle": "历史记录", "historyLoading": "加载中...", "historyEmpty": "暂无日记", "untitled": "无标题", "titleLabel": "标题", "titlePlaceholder": "可选标题", "dateLabel": "日期", "moodLabel": "情绪", "moodPlaceholder": "选择情绪", "moodCalm": "平静", "moodFocused": "专注", "moodTired": "疲惫", "moodEnergized": "充满能量", "moodAnxious": "焦虑", "energyLabel": "精力", "energyPlaceholder": "选择精力", "energyLow": "低", "energyMid": "中", "energyHigh": "高", "tagsLabel": "标签", "tagsPlaceholder": "逗号分隔", "relatedTodosLabel": "关联待办", "relatedTodosPlaceholder": "待办ID", "relatedActivitiesLabel": "关联活动", "relatedActivitiesPlaceholder": "活动ID", "settingsTitle": "每日刷新点", "refreshModeLabel": "刷新模式", "refreshModeFixed": "固定时间", "refreshModeWorkHours": "工作时段", "refreshModeCustom": "自定义", "fixedTimeLabel": "固定时间", "workHoursLabel": "工作时段", "customTimeLabel": "自定义时间", "autoLinkToggle": "保存后自动关联", "autoObjectiveToggle": "自动生成客观记录", "autoAiToggle": "自动生成 AI 视角", "tabOriginal": "原文", "tabObjective": "客观记录", "tabAi": "AI 视角", "save": "保存", "saving": "保存中...", "jumpToToday": "回到今天", "generateObjective": "生成客观记录", "generatingObjective": "生成中...", "generateAi": "生成 AI 视角", "generatingAi": "生成中...", "autoLink": "自动关联", "autoLinking": "关联中...", "copyToOriginal": "复制到原文", "contentPlaceholder": "写下今天的想法...", "objectivePlaceholder": "点击生成客观记录", "aiPlaceholder": "点击生成 AI 视角", "saveSuccess": "已保存", "saveFailed": "保存失败", "autoLinkSuccess": "已关联 {todoCount} 个待办,{activityCount} 个活动", "autoLinkFailed": "自动关联失败", "generateFailed": "生成失败", "loadFailed": "加载失败: {error}" }, "bottomDock": { "calendar": "日历", "activity": "活动", "todos": "待办", "chat": "聊天", "todoDetail": "待办详情", "diary": "日记", "settings": "设置", "costTracking": "费用", "achievements": "成就", "screenshots": "截图", "debugShots": "截图调试", "audio": "音频", "unassigned": "未分配" }, "panelMenu": { "moreActions": "面板操作", "switchPanel": "切换面板", "closePanel": "关闭面板", "pinPanel": "固定面板", "unpinPanel": "解锁面板", "openInNewWindow": "在新窗口中打开", "pinnedBadge": "已固定" }, "todoExtraction": { "extractButton": "提取待办", "extracting": "正在提取待办事项...", "extractSuccess": "提取到 {count} 个待办事项", "extractFailed": "提取待办失败:{error}", "noTodosFound": "未发现待办事项", "notWhitelistApp": "该应用不支持待办提取", "modalTitle": "待办事项确认", "modalDescription": "请选择要添加到待办列表的项:", "selectAll": "全选", "deselectAll": "取消全选", "confirmAdd": "确认添加 ({count})", "cancel": "取消", "todoTitle": "标题", "todoDescription": "描述", "todoTime": "时间", "todoSource": "来源", "todoScheduledTime": "计划时间", "source": "来源", "time": "时间", "eventId": "事件ID", "addSuccess": "成功添加 {count} 个待办事项", "addFailed": "添加待办失败:{error}", "selectedCount": "已选择 {count} 项", "newDraftTodoNotification": "新待办事项待确认", "accept": "同意", "reject": "拒绝", "accepting": "处理中...", "rejecting": "处理中...", "acceptSuccess": "已添加到待办列表", "rejectSuccess": "已拒绝", "acceptFailed": "操作失败:{error}", "rejectFailed": "操作失败:{error}", "autoExtracted": "自动提取", "noTimeSpecified": "未指定时间", "failedItems": "失败 {count} 项", "newNotification": "新通知", "collapseNotification": "收起通知", "expandNotification": "展开通知", "closeNotification": "关闭通知", "justNow": "刚刚", "minutesAgo": "{count}分钟前", "hoursAgo": "{count}小时前", "daysAgo": "{count}天前", "dateFormat": "{month}月{day}日", "llmConfigMissing": "请配置 AI 服务", "llmConfigMissingHint": "点击设置 API Key" }, "audio": { "extractionSummary": "已提取 {todoCount} 个待办,{scheduleCount} 个日程", "linkTodo": "关联到待办", "extractionModalTitle": "提取结果确认", "extractionModalDesc": "请选择要添加到待办列表的日程/待办项:", "todoSection": "待办({count})", "scheduleSection": "日程({count})", "scheduleTag": "日程", "linkTodoTag": "提取", "scheduleFallbackTitle": "日程", "noExtractions": "暂无可添加的提取结果", "selectedCount": "已选择 {count} 项", "confirmAdd": "确认添加 ({count})", "addSuccess": "已添加 {count} 项", "addFailed": "添加失败 {count} 项" }, "chat": { "greetings": { "title": "Free Todo,放手去做", "subtitle": "智能待办助手,帮你拆解任务、规划优先级、提升效率" }, "planModeInputPlaceholder": "例如:帮我规划周末搬家需要做的事", "difyTest": { "inputPlaceholder": "这里是 Dify 测试通道的提示词..." }, "aiThinking": "AI 正在思考...", "generatingQuestions": "AI正在拆解待办...", "generateQuestionsFailed": "拆解待办失败,请重试", "generateSummaryFailed": "生成总结失败,请重试", "loading": "加载中", "suggestions": { "breakdown": "拆解任务", "breakdownPrompt": "帮我拆解一下这个任务", "plan": "制定计划", "planPrompt": "帮我制定一个详细的计划", "priority": "优先级排序", "priorityPrompt": "帮我按优先级排序这些任务", "time": "时间管理", "timePrompt": "给我一些时间管理的建议", "review": "任务回顾", "reviewPrompt": "帮我回顾一下最近的任务完成情况", "optimize": "优化工作流", "optimizePrompt": "如何优化我的工作流程?", "goal": "目标设定", "goalPrompt": "帮我设定一些目标", "advice": "获取建议", "advicePrompt": "针对目前的待办,给我一些建议" }, "assistant": "助理", "user": "我", "chatMode": "对话模式", "toggleMode": "切换 Ask/Plan/Edit 模式", "mentionFileOrTodo": "提及文件或任务", "send": "发送", "stop": "停止", "linkedTodos": "关联待办({count})", "linkedTodosEn": "Linked todos ({count})", "collapse": "收起", "expand": "展开", "clearSelection": "清空选择", "generatingQuestion": "正在生成第 {count} 个问题", "answerQuestions": "请回答以下问题以完善任务详情", "answerQuestionsDesc": "您的回答将帮助AI更好地理解任务需求并生成详细的计划", "multipleChoice": "(可多选)", "customAnswer": "自定义回答", "customAnswerPlaceholder": "请输入您的自定义回答...", "submitting": "提交中...", "submitAnswer": "提交回答", "answeredProgress": "已回答 {answered}/{total} 个问题", "generatingSummary": "AI正在生成总结...", "generatingSummaryDesc": "请稍候,AI正在根据您的回答生成详细的任务总结和子任务列表", "generating": "生成中...", "parsingContent": "正在解析完整内容...", "noTodoContext": "无待办上下文", "userInput": "用户输入", "loadHistoryFailed": "加载历史记录失败", "noTodosAvailable": "当前没有待办,聊天上下文为空。", "noTodosFound": "未发现待办事项", "extractModalDescription": "请选择要添加的待办事项:", "selectedCount": "已选择 {count} 项", "confirmAdd": "确认添加 ({count})", "applying": "正在应用...", "selectedTodo": "【当前选中待办】", "rootParentTodo": "【最高级父待办】", "allSubTodos": "【该父待办下的所有子待办】(共 {count} 条)", "allSubTodosRoot": "【所有子待办】(共 {count} 条)", "todoContextHeader": "{source}(共 {count} 条):", "todoContextHeaderEn": "{source} (total {count}):", "noPlanJsonFound": "未找到计划 JSON,未创建待办。", "parsedNoValidTodos": "解析完成,但没有有效的待办项。", "parsePlanJsonFailed": "解析计划 JSON 失败,未创建待办。", "noResponseReceived": "没有收到回复,请稍后再试。", "addedTodos": "已添加 {count} 条待办到列表。", "errorOccurred": "出错了,请稍后再试。", "breakdownSummary": { "title": "任务拆分结果", "description": "根据您的回答,我们已将任务拆分为以下子任务:", "taskSummary": "任务总结", "subtaskList": "子任务列表", "noSubtasks": "暂无子任务", "applying": "正在应用...", "acceptAndApply": "接受并应用" }, "planSummary": { "title": "待办规划结果", "description": "请查看AI生成的待办总结和子待办列表,确认后点击接收", "taskSummary": "待办总结", "subtaskList": "子待办列表", "noSubtasks": "没有生成子待办", "applying": "应用中...", "acceptAndApply": "接收并应用" }, "editMode": { "inputPlaceholder": "描述你想要生成的内容...", "appendTo": "追加到", "appendSuccess": "已追加到待办备注", "appendFailed": "追加失败", "selectTodo": "选择待办", "noLinkedTodos": "请先关联待办", "append": "追加", "appending": "追加中", "appended": "已追加", "failed": "失败", "aiRecommended": "推荐" }, "modes": { "ask": { "label": "Ask 模式", "description": "直接聊天或提问" }, "plan": { "label": "Plan 模式", "description": "拆解需求并生成待办" }, "edit": { "label": "Edit 模式", "description": "生成内容追加到待办备注" }, "difyTest": { "label": "Dify Test 模式", "description": "通过 Dify 测试通道返回回复" }, "agno": { "label": "Agno 模式", "description": "基于 Agno 框架的智能 Agent" }, "active": "当前" }, "webSearch": { "label": "联网搜索", "toggle": "切换联网搜索", "enabled": "联网搜索已启用", "disabled": "联网搜索已禁用" }, "toolSelector": { "label": "工具", "title": "选择工具", "selectAll": "全选", "deselectAll": "取消全选", "selectedCount": "已选择 {count} 个工具", "allSelected": "使用所有工具", "noneSelected": "未选择任何工具", "externalTools": "外部工具", "freetodoTools": "待办工具", "categories": { "todo": "待办管理", "breakdown": "任务拆解", "time": "时间解析", "conflict": "冲突检测", "stats": "统计分析", "tags": "标签管理", "search": "联网搜索" }, "externalCategories": { "search": "搜索类", "local": "本地类" } }, "sources": "来源", "todoContext": { "id": "ID", "name": "名称", "description": "描述", "notes": "备注", "deadline": "时间", "priority": "优先级", "status": "状态", "tags": "标签", "parentTodoId": "父待办ID", "parentName": "父待办名称", "due": "时间: {deadline}", "tagsLabel": "标签: {tags}" }, "toolCall": { "calling": "正在调用 {tool}...", "completed": "{tool} 执行完成", "failed": "{tool} 执行失败", "result": "结果", "tools": { "create_todo": "创建待办", "complete_todo": "完成待办", "update_todo": "更新待办", "list_todos": "列出待办", "search_todos": "搜索待办", "delete_todo": "删除待办", "breakdown_task": "任务拆解", "parse_time": "时间解析", "check_schedule_conflict": "冲突检测", "get_todo_stats": "统计分析", "get_overdue_todos": "逾期待办", "list_tags": "列出标签", "get_todos_by_tag": "按标签查询", "suggest_tags": "标签推荐", "web_search": "联网搜索", "search_news": "新闻搜索", "duckduckgo": "DuckDuckGo 搜索", "websearch": "网页搜索", "hackernews": "Hacker News", "file": "文件操作", "local_fs": "文件写入", "shell": "命令行", "sleep": "暂停执行", "unknown": "未知工具" } } }, "calendar": { "title": "日历", "monthView": "月视图", "weekView": "周视图", "dayView": "日视图", "today": "今天", "previous": "上一段", "next": "下一段", "create": "创建", "yearMonth": "{year} 年 {month} 月", "yearMonthWeek": "{year} 年 {month} 月(第{week}周)", "yearMonthDay": "{year} 年 {month} 月 {day} 日", "createOnDate": "在 {date} 创建待办", "closeCreate": "关闭创建", "inputTodoTitle": "输入待办标题...", "noTodosDue": "无截止待办,点击下方创建一个吧", "allDay": "全天", "startTime": "开始", "endTime": "结束", "floating": "未定时", "floatingEmpty": "暂无未定时任务", "workingHours": "工作时间", "weekdays": { "monday": "一", "tuesday": "二", "wednesday": "三", "thursday": "四", "friday": "五", "saturday": "六", "sunday": "日" }, "weekPrefix": "周" }, "reminder": { "label": "提醒", "noReminder": "不提醒", "atTime": "准时", "minutesBefore": "{count} 分钟前", "hoursBefore": "{count} 小时前", "daysBefore": "{count} 天前", "custom": "自定义", "add": "添加", "clear": "清空", "needsDeadline": "提醒会基于截止时间触发", "needsSchedule": "提醒会基于时间触发", "unit": { "minutes": "分钟", "hours": "小时", "days": "天" } }, "datePicker": { "dateTab": "日期", "rangeTab": "时间段", "dateLabel": "日期", "timeLabel": "时间", "pickDate": "选择日期", "allDay": "全天", "repeatLabel": "重复", "repeatNone": "不重复", "repeatDaily": "每天", "repeatWeekly": "每周", "repeatMonthly": "每月", "repeatYearly": "每年", "rangeLabel": "时间段", "startLabel": "开始", "endLabel": "结束", "timeZoneLabel": "时区", "time": "时间", "timeRange": "时间段", "startTime": "开始时间", "endTime": "结束时间", "clear": "清除", "confirm": "确定" }, "layoutSelector": { "label": "布局", "selectLayout": "选择布局", "customLayout": "自定义布局", "saveCurrentLayout": "保存当前布局", "layoutActions": "布局操作", "saveLayoutTitle": "保存布局", "saveLayoutDescription": "为当前布局命名", "saveLayoutPlaceholder": "输入布局名称", "renameLayout": "重命名", "renameLayoutTitle": "重命名布局", "renameLayoutDescription": "为该布局设置新名称", "renameLayoutPlaceholder": "输入新名称", "deleteLayout": "删除", "deleteLayoutTitle": "删除布局", "deleteLayoutDescription": "确定要删除“{name}”吗?此操作无法撤销。", "overwriteConfirmTitle": "覆盖已有布局", "overwriteConfirmDescription": "已存在名为“{name}”的布局,是否覆盖?", "layoutNameRequired": "布局名称不能为空", "confirm": "确定", "cancel": "取消", "close": "关闭", "layouts": { "default": "待办列表模式", "calendar": "待办日历模式", "lifetrace": "LifeTrace 模式" } }, "scheduler": { "title": "定时任务管理", "description": "管理后台定时任务的运行状态和执行间隔", "running": "运行中", "paused": "已暂停", "schedulerRunning": "调度器运行中", "schedulerStopped": "调度器已停止", "runningCount": "{running} 运行 / {paused} 暂停", "refresh": "刷新", "pauseAll": "全部暂停", "resumeAll": "全部恢复", "pause": "暂停", "resume": "恢复", "interval": "间隔", "next": "下次", "hour": "时", "minute": "分", "second": "秒", "save": "保存", "cancel": "取消", "editInterval": "编辑间隔", "noJobs": "暂无定时任务", "loading": "加载中...", "legacyJobs": "旧版任务", "legacyNotNeeded": "此前端不需要", "intervalCannotBeZero": "间隔时间不能为0", "jobPaused": "任务 {job} 已暂停", "jobResumed": "任务 {job} 已恢复", "pauseFailed": "暂停失败: {error}", "resumeFailed": "恢复失败: {error}", "allJobsPaused": "已暂停所有任务", "allJobsResumed": "已恢复所有任务", "intervalUpdated": "任务 {job} 间隔已更新", "updateFailed": "更新失败: {error}", "dateLocale": "zh-CN", "jobs": { "recorder_job": "屏幕录制", "ocr_job": "文字识别", "task_context_mapper_job": "任务上下文关联", "task_summary_job": "任务总结", "clean_data_job": "数据清理", "activity_aggregator_job": "活动聚合", "deadline_reminder_job": "DDL提醒", "todo_recorder_job": "屏幕录制(Todo生成)", "proactive_ocr_job": "主动OCR", "audio_recording_job": "音频录制" }, "jobDescriptions": { "recorder_job": "定时截取屏幕截图", "ocr_job": "识别截图中的文字内容", "task_context_mapper_job": "(旧版)关联截图与任务上下文", "task_summary_job": "(旧版)生成任务执行总结", "clean_data_job": "清理过期的截图和数据", "activity_aggregator_job": "聚合用户活动事件", "deadline_reminder_job": "检查待办事项的截止日期,根据提醒设置发送通知", "todo_recorder_job": "仅录制白名单应用的截图,用于自动待办检测", "proactive_ocr_job": "自动检测并处理微信/飞书窗口进行OCR识别(仅Windows)", "audio_recording_job": "7x24小时持续录音,实时转录和提取信息(间隔为状态检查间隔,非录音间隔)" } }, "common": { "priority": { "high": "高", "medium": "中", "low": "低", "none": "无" }, "status": { "active": "进行中", "completed": "已完成", "canceled": "已取消", "draft": "草稿" }, "numberLocale": "zh-CN" }, "debugCapture": { "title": "截图管理(开发调试)", "screenshotDetail": "截图详情", "close": "关闭", "loading": "加载中...", "loadFailed": "加载失败", "imageLoadFailed": "图片加载失败", "screenshotId": "截图 ID", "screenshot": "截图", "previous": "上一张", "next": "下一张", "details": "详细信息", "time": "时间", "app": "应用", "unknown": "未知", "windowTitle": "窗口标题", "none": "无", "size": "尺寸", "ocrResult": "OCR 结果", "selectedEvents": "已选择 {count} 个事件", "aggregating": "聚合中...", "aggregateActivity": "聚合为活动 ({count})", "clearSelection": "清空选择", "startDate": "开始日期", "endDate": "结束日期", "appName": "应用名称", "appNamePlaceholder": "应用名称", "search": "搜索", "eventTimeline": "事件时间轴", "foundEvents": "找到 {total} 个事件", "loadedEvents": "(已加载 {loaded} 个)", "loadingMore": "加载更多中...", "scrollToLoadMore": "滚动到底部加载更多", "allEventsLoaded": "已加载所有事件", "noEventsFound": "未找到事件", "adjustSearchCriteria": "请调整搜索条件", "eventsCount": "{count} 个事件", "select": "选择", "deselect": "取消选择", "unknownWindow": "未知窗口", "duration": "时长 {duration}", "inProgress": "进行中", "noDescription": "无描述", "screenshotCount": "{count} 张", "selectEventsPrompt": "请先选择要聚合的事件", "unendedEventsError": "所选事件中包含未结束的事件,无法聚合", "activityCreated": "成功创建活动: {title}\n包含 {count} 个事件", "activity": "活动", "aggregateFailed": "聚合事件失败,请稍后重试", "extractFailed": "提取待办失败,请稍后重试" }, "achievements": { "title": "成就系统", "placeholder": "占位:在这里接入成就组件", "achievement1": { "name": "初出茅庐", "description": "完成第一个待办" }, "achievement2": { "name": "待办达人", "description": "完成 10 个待办" }, "achievement3": { "name": "效率之星", "description": "连续 7 天完成任务" }, "achievement4": { "name": "完美主义者", "description": "完成 100 个任务" } }, "todoList": { "addTodo": "添加待办", "add": "添加", "submit": "提交", "reset": "重置", "searchPlaceholder": "搜索待办...", "loadFailed": "加载失败: {error}", "noTodos": "暂无待办事项", "deadline": "日期", "description": "描述", "descriptionPlaceholder": "描述任务细节...", "tags": "标签(逗号分隔)", "tagsPlaceholder": "如:工作, 报告", "priority": "优先级", "notes": "备注", "notesPlaceholder": "个人备注或行动项", "filter": "筛选", "filterStatus": "状态", "filterTag": "标签", "filterDueTime": "时间", "filterAll": "全部", "statusActive": "进行中", "statusCompleted": "已完成", "statusCanceled": "已取消", "statusDraft": "草稿", "dueTimeOverdue": "已逾期", "dueTimeToday": "今天", "dueTimeTomorrow": "明天", "dueTimeThisWeek": "本周", "dueTimeThisMonth": "本月", "dueTimeFuture": "未来", "clearFilters": "清除筛选", "quickFilters": "快速筛选", "moreOptions": "更多选项" }, "todoDetail": { "editTitle": "点击编辑标题", "viewDescription": "查看描述", "selectTodoPrompt": "请选择一个待办事项查看详情", "detailViewLabel": "Detail", "artifactsViewLabel": "Artifacts", "markAsComplete": "标记为完成", "delete": "删除", "addDeadline": "添加日期", "addTags": "添加标签", "tagsPlaceholder": "使用逗号分隔多个标签", "save": "保存", "cancel": "取消", "clear": "清空", "current": "当前", "priorityLabel": "优先级:{priority}", "setAsChild": "设为子任务", "collapseSubTasks": "折叠子任务", "expandSubTasks": "展开子任务", "addChildPlaceholder": "输入子待办名称...", "add": "添加", "addChild": "添加子待办", "childTodos": "子待办", "useAiPlan": "AI拆解任务", "useAiPlanTitle": "使用AI拆解任务", "getAdvice": "获取建议", "getAdviceTitle": "针对此任务获取AI建议", "descriptionLabel": "描述", "backgroundLabel": "背景", "notesLabel": "备注", "descriptionEmptyPlaceholder": "暂无描述(点击添加)", "backgroundPlaceholder": "输入背景信息...", "backgroundEmptyPlaceholder": "暂无背景(点击添加)", "notesPlaceholder": "在此输入备注...", "notesEmptyPlaceholder": "暂无备注", "progressLabel": "Progress", "progressEmptyTitle": "暂无工作台任务", "progressEmptyHint": "生成产物时会在这里显示进度。", "artifactsLabel": "产物", "artifactsEmpty": "暂无产物", "contextLabel": "上下文", "editContext": "编辑详情", "contextAttachmentsLabel": "上下文附件", "contextAttachmentsEmpty": "暂无附件", "uploadLabel": "上传", "uploadHint": "单文件不超过 50MB", "previewLabel": "预览", "previewUnavailable": "该格式暂不支持预览", "downloadLabel": "下载", "uploadFailed": "上传失败", "removeAttachmentFailed": "移除附件失败", "uploadSizeLimit": "单文件不能超过 50MB", "deleteConfirmTitle": "确认删除此待办?", "deleteConfirmDescription": "该操作无法撤销。", "deleteConfirmWithChildren": "将同时删除此待办及其 {count} 个子待办。", "deleteConfirmCancel": "取消", "deleteConfirmDelete": "删除" }, "automationTasks": { "title": "自定义定时任务", "description": "创建轻量定时任务,比如在指定时间抓取内容。", "createTitle": "新建任务", "createHint": "暂时仅支持网页抓取", "empty": "暂无定时任务", "dateLocale": "zh-CN", "labels": { "name": "任务名称", "description": "描述", "url": "抓取 URL", "method": "请求方式", "enabled": "启用", "enabledOn": "已启用", "enabledOff": "已停用", "scheduleType": "调度类型", "intervalMinutes": "间隔(分钟)", "cron": "Cron 表达式", "runAt": "执行时间", "lastRun": "上次执行:{time}", "status": "状态" }, "placeholders": { "name": "每日检查", "description": "可选备注" }, "scheduleType": { "interval": "固定间隔", "cron": "Cron", "once": "仅一次" }, "scheduleSummary": { "interval": "每 {minutes} 分钟", "cron": "Cron:{cron}", "once": "执行于 {time}" }, "actions": { "create": "创建任务", "run": "立即执行", "enable": "启用", "disable": "停用", "delete": "删除" }, "status": { "success": "成功", "error": "失败", "never": "从未" }, "errors": { "nameRequired": "需要填写任务名称", "urlRequired": "需要填写 URL", "intervalRequired": "间隔必须大于 0", "cronRequired": "需要填写 Cron 表达式", "runAtRequired": "需要选择执行时间", "createFailed": "创建任务失败:{error}", "runFailed": "执行任务失败:{error}", "updateFailed": "更新任务失败:{error}", "deleteFailed": "删除任务失败:{error}" }, "messages": { "created": "任务已创建", "ran": "任务已执行", "enabled": "任务已启用", "disabled": "任务已停用", "deleted": "任务已删除" }, "confirmDelete": "确认删除该任务?" }, "contextMenu": { "selectedCount": "已选中 {count} 项", "batchCancel": "批量放弃", "batchDelete": "批量删除", "addChild": "添加子待办", "useAiPlan": "使用AI规划", "cancel": "放弃", "delete": "删除", "childNamePlaceholder": "输入子待办名称...", "cancelButton": "取消", "addButton": "添加", "extractButton": "提取待办", "extracting": "正在提取待办事项..." }, "onboarding": { "welcomeTitle": "欢迎使用 Free Todo", "welcomeDescription": "快速配置你的 AI 助手。", "apiKeyStepTitle": "API Key", "apiKeyStepDescription": "填写阿里云百炼大模型 API Key。", "dockTriggerTitle": "下移鼠标", "dockTriggerDescription": "将鼠标移至底部边缘,Dock 会自动出现。", "dockStepTitle": "底部 Dock", "dockStepDescription": "单击以开关面板,拖拽以调整顺序。", "dockRightClickTitle": "尝试右键点击", "dockRightClickDescription": "右键点击此项目查看更多选项。", "dockMenuTitle": "面板选择器", "dockMenuDescription": "选择一个面板进行切换,试试点击「聊天」。", "completeTitle": "准备就绪!", "completeDescription": "开始使用 AI 助手管理待办事项。", "nextBtn": "下一步", "prevBtn": "上一步", "doneBtn": "开始使用", "skipBtn": "跳过引导", "restartTour": "重新开始引导", "restartTourDescription": "点击此按钮可以随时开始或重新查看新手引导流程" } } ================================================ FILE: free-todo-frontend/lib/i18n/request.ts ================================================ import { cookies, headers } from "next/headers"; import { getRequestConfig } from "next-intl/server"; // Supported locales - add new languages here // Must match the files in ./messages/ directory const SUPPORTED_LOCALES = ["zh", "en"] as const; type Locale = (typeof SUPPORTED_LOCALES)[number]; // Default locale when no match is found const DEFAULT_LOCALE: Locale = "en"; const isValidLocale = (value: string | undefined): value is Locale => { return value !== undefined && SUPPORTED_LOCALES.includes(value as Locale); }; /** * 从 Accept-Language header 解析用户偏好的语言 * 格式示例: "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6" */ function parseAcceptLanguage(acceptLanguage: string | null): Locale | null { if (!acceptLanguage) return null; // 解析并按权重排序 const languages = acceptLanguage .split(",") .map((lang) => { const [code, qValue] = lang.trim().split(";q="); return { // 取语言代码的前缀部分 (zh-CN -> zh, en-US -> en) code: code.split("-")[0].toLowerCase(), // 默认权重为 1 q: qValue ? Number.parseFloat(qValue) : 1, }; }) .sort((a, b) => b.q - a.q); // 找到第一个匹配的支持语言 for (const { code } of languages) { if (SUPPORTED_LOCALES.includes(code as Locale)) { return code as Locale; } } return null; } export default getRequestConfig(async () => { const cookieStore = await cookies(); const localeCookie = cookieStore.get("locale")?.value; let locale: Locale; if (isValidLocale(localeCookie)) { // 优先使用用户手动选择的语言(cookie) locale = localeCookie; } else { // Cookie 为空时,从 Accept-Language header 检测浏览器语言 const headerStore = await headers(); const acceptLanguage = headerStore.get("accept-language"); const browserLocale = parseAcceptLanguage(acceptLanguage); locale = browserLocale ?? DEFAULT_LOCALE; } return { locale, messages: (await import(`./messages/${locale}.json`)).default, }; }); ================================================ FILE: free-todo-frontend/lib/island/types.ts ================================================ /** * Island 动态岛类型定义 */ /** * 动态岛模式枚举 */ export enum IslandMode { /** 悬浮小窗 - 始终可见的小药丸形状 (180×48px) */ FLOAT = "FLOAT", /** 弹出通知 - 中等大小的通知卡片 (340×110px) */ POPUP = "POPUP", /** 侧边栏 - 侧边面板 (400px 宽, ~500px 高) */ SIDEBAR = "SIDEBAR", /** 全屏 - 全屏显示 */ FULLSCREEN = "FULLSCREEN", } /** * 各模式对应的窗口尺寸配置 */ export const ISLAND_SIZES: Record = { [IslandMode.FLOAT]: { width: 200, height: 56 }, // 紧凑胶囊设计,黄金比例布局 [IslandMode.POPUP]: { width: 380, height: 120 }, [IslandMode.SIDEBAR]: { width: 420, height: 700 }, [IslandMode.FULLSCREEN]: { width: 0, height: 0 }, // 全屏时使用屏幕尺寸 }; /** * SIDEBAR 模式各栏数的窗口尺寸配置 */ export const SIDEBAR_COLUMN_SIZES: Record<1 | 2 | 3, { width: number; height: number }> = { 1: { width: 420, height: 700 }, // 单栏(现有) 2: { width: 800, height: 700 }, // 双栏 3: { width: 1200, height: 700 }, // 三栏 }; /** * Island 窗口配置 */ export const ISLAND_WINDOW_CONFIG = { /** 默认初始模式 */ defaultMode: IslandMode.FLOAT, /** 距离屏幕右边缘的距离 */ marginRight: 20, /** 距离屏幕顶部的距离 */ marginTop: 20, /** 窗口背景色(透明) */ backgroundColor: "#00000000", }; ================================================ FILE: free-todo-frontend/lib/plugins/registry.ts ================================================ import { type ComponentType, type LazyExoticComponent, lazy } from "react"; import type { PanelFeature } from "@/lib/config/panel-config"; import { FEATURE_ICON_MAP } from "@/lib/config/panel-config"; export type PanelPlugin = { id: PanelFeature; labelKey: string; placeholderKey: string; icon: (typeof FEATURE_ICON_MAP)[PanelFeature]; loader?: () => Promise<{ default: ComponentType }>; backendModules?: string[]; }; const panelRegistry: Record = { calendar: { id: "calendar", labelKey: "calendarLabel", placeholderKey: "calendarPlaceholder", icon: FEATURE_ICON_MAP.calendar, backendModules: ["event"], loader: () => import("@/apps/calendar/CalendarPanel").then((mod) => ({ default: mod.CalendarPanel, })), }, activity: { id: "activity", labelKey: "activityLabel", placeholderKey: "activityPlaceholder", icon: FEATURE_ICON_MAP.activity, backendModules: ["activity"], loader: () => import("@/apps/activity/ActivityPanel").then((mod) => ({ default: mod.ActivityPanel, })), }, todos: { id: "todos", labelKey: "todosLabel", placeholderKey: "todosPlaceholder", icon: FEATURE_ICON_MAP.todos, backendModules: ["todo"], loader: () => import("@/apps/todo-list").then((mod) => ({ default: mod.TodoList, })), }, chat: { id: "chat", labelKey: "chatLabel", placeholderKey: "chatPlaceholder", icon: FEATURE_ICON_MAP.chat, backendModules: ["chat"], loader: () => import("@/apps/chat/ChatPanel").then((mod) => ({ default: mod.ChatPanel, })), }, todoDetail: { id: "todoDetail", labelKey: "todoDetailLabel", placeholderKey: "todoDetailPlaceholder", icon: FEATURE_ICON_MAP.todoDetail, backendModules: ["todo"], loader: () => import("@/apps/todo-detail").then((mod) => ({ default: mod.TodoDetail, })), }, diary: { id: "diary", labelKey: "diaryLabel", placeholderKey: "diaryPlaceholder", icon: FEATURE_ICON_MAP.diary, backendModules: ["journal"], loader: () => import("@/apps/diary").then((mod) => ({ default: mod.DiaryPanel, })), }, settings: { id: "settings", labelKey: "settingsLabel", placeholderKey: "settingsPlaceholder", icon: FEATURE_ICON_MAP.settings, backendModules: ["config"], loader: () => import("@/apps/settings").then((mod) => ({ default: mod.SettingsPanel, })), }, costTracking: { id: "costTracking", labelKey: "costTrackingLabel", placeholderKey: "costTrackingPlaceholder", icon: FEATURE_ICON_MAP.costTracking, backendModules: ["cost_tracking"], loader: () => import("@/apps/cost-tracking").then((mod) => ({ default: mod.CostTrackingPanel, })), }, achievements: { id: "achievements", labelKey: "achievementsLabel", placeholderKey: "achievementsPlaceholder", icon: FEATURE_ICON_MAP.achievements, loader: () => import("@/apps/achievements/AchievementsPanel").then((mod) => ({ default: mod.AchievementsPanel, })), }, debugShots: { id: "debugShots", labelKey: "debugShotsLabel", placeholderKey: "debugShotsPlaceholder", icon: FEATURE_ICON_MAP.debugShots, backendModules: ["event"], loader: () => import("@/apps/debug/DebugCapturePanel").then((mod) => ({ default: mod.DebugCapturePanel, })), }, audio: { id: "audio", labelKey: "audioLabel", placeholderKey: "audioPlaceholder", icon: FEATURE_ICON_MAP.audio, backendModules: ["audio"], loader: () => import("@/apps/audio/AudioPanel").then((mod) => ({ default: mod.AudioPanel, })), }, }; const lazyPanelCache = new Map>(); export function getPanelPlugin(feature: PanelFeature | null): PanelPlugin | null { if (!feature) return null; return panelRegistry[feature] ?? null; } export function getPanelPlugins(): PanelPlugin[] { return Object.values(panelRegistry); } export function getPanelLazyComponent( feature: PanelFeature | null, ): LazyExoticComponent | null { if (!feature) return null; const plugin = panelRegistry[feature]; if (!plugin?.loader) return null; const cached = lazyPanelCache.get(feature); if (cached) return cached; const lazyComponent = lazy(plugin.loader); lazyPanelCache.set(feature, lazyComponent); return lazyComponent; } ================================================ FILE: free-todo-frontend/lib/query/activities.ts ================================================ "use client"; import { useQuery } from "@tanstack/react-query"; import { useGetActivityEventsApiActivitiesActivityIdEventsGet, useListActivitiesApiActivitiesGet, } from "@/lib/generated/activity/activity"; import { useGetEventDetailApiEventsEventIdGet, useListEventsApiEventsGet, } from "@/lib/generated/event/event"; import type { Activity, ActivityEventsResponse, ActivityListResponse, ActivityWithEvents, Event, EventListResponse, } from "@/lib/types"; import { queryKeys } from "./keys"; // ============================================================================ // Helper Functions // ============================================================================ /** * Normalize API response to ensure consistent Activity type * Now that fetcher auto-converts snake_case -> camelCase */ function normalizeActivity(raw: Record): Activity { return { id: raw.id as number, startTime: raw.startTime as string, endTime: raw.endTime as string, aiTitle: (raw.aiTitle as string) ?? undefined, aiSummary: (raw.aiSummary as string) ?? undefined, eventCount: raw.eventCount as number, createdAt: (raw.createdAt as string) ?? undefined, updatedAt: (raw.updatedAt as string) ?? undefined, }; } /** * Normalize API response to ensure consistent Event type */ function normalizeEvent(raw: Record): Event { const screenshots = (raw.screenshots as unknown[]) || []; const screenshotCount = screenshots.length ?? 0; const firstScreenshotId = ((screenshots[0] as Record)?.id as number) ?? undefined; return { id: raw.id as number, appName: (raw.appName as string) || "", windowTitle: (raw.windowTitle as string) || "", startTime: raw.startTime as string, endTime: (raw.endTime as string) ?? undefined, screenshotCount, firstScreenshotId, aiTitle: (raw.aiTitle as string) ?? undefined, aiSummary: (raw.aiSummary as string) ?? undefined, screenshots: screenshots as Event["screenshots"], }; } // ============================================================================ // Query Hooks // ============================================================================ interface UseActivitiesParams { limit?: number; offset?: number; start_date?: string; end_date?: string; } /** * 获取 Activity 列表的 Query Hook * 使用 Orval 生成的 hook */ export function useActivities(params?: UseActivitiesParams) { return useListActivitiesApiActivitiesGet( { limit: params?.limit ?? 50, offset: params?.offset ?? 0, start_date: params?.start_date, end_date: params?.end_date, }, { query: { queryKey: queryKeys.activities.list(params), staleTime: 30 * 1000, select: (data: unknown) => { // Data is now auto-converted to camelCase by the fetcher const response = data as ActivityListResponse; return (response?.activities ?? []).map((raw) => normalizeActivity(raw as unknown as Record), ); }, }, }, ); } /** * 获取单个 Activity 的事件 ID 列表 * 使用 Orval 生成的 hook */ export function useActivityEvents(activityId: number | null) { return useGetActivityEventsApiActivitiesActivityIdEventsGet(activityId ?? 0, { query: { queryKey: queryKeys.activities.events(activityId ?? 0), enabled: activityId !== null, staleTime: 60 * 1000, select: (data: unknown) => { // Data is now auto-converted to camelCase by the fetcher const response = data as ActivityEventsResponse; return response?.eventIds ?? []; }, }, }); } /** * 获取单个 Event 详情的 Query Hook * 使用 Orval 生成的 hook */ export function useEvent(eventId: number | null) { return useGetEventDetailApiEventsEventIdGet(eventId ?? 0, { query: { queryKey: queryKeys.events.detail(eventId ?? 0), enabled: eventId !== null, staleTime: 60 * 1000, select: (data: unknown) => { if (!data) return null; return normalizeEvent(data as Record); }, }, }); } /** * 批量获取多个 Event 详情的 Query Hook * 使用自定义查询组合多个 event 请求 */ export function useEvents(eventIds: number[]) { return useQuery({ queryKey: ["events", "batch", eventIds], queryFn: async () => { if (eventIds.length === 0) return []; // 使用 Orval 生成的 fetcher 函数 const { getEventDetailApiEventsEventIdGet } = await import( "@/lib/generated/event/event" ); const results = await Promise.all( eventIds.map(async (id) => { try { const data = await getEventDetailApiEventsEventIdGet(id); if (!data) return null; return normalizeEvent(data as unknown as Record); } catch (error) { console.error("Failed to load event", id, error); return null; } }), ); return results.filter((e): e is Event => e !== null); }, enabled: eventIds.length > 0, staleTime: 60 * 1000, }); } interface UseEventsListParams { limit?: number; offset?: number; start_date?: string; end_date?: string; app_name?: string; } /** * 获取 Event 列表的 Query Hook * 使用 Orval 生成的 hook */ export function useEventsList(params?: UseEventsListParams) { return useListEventsApiEventsGet(params, { query: { queryKey: queryKeys.events.list(params), staleTime: 30 * 1000, select: (data: unknown) => { const response = data as EventListResponse; return response?.events ?? []; }, }, }); } // ============================================================================ // 组合 Hook:获取 Activity 详情(包含关联的 Events) // ============================================================================ /** * 获取 Activity 详情及其关联的 Events * 组合了 activities、activity events 和 event details 三个查询 */ export function useActivityWithEvents( activityId: number | null, activities: Activity[], ) { // 获取 activity 的事件 ID 列表 const { data: eventIds = [], isLoading: isLoadingEvents, error: eventsError, } = useActivityEvents(activityId); // 批量获取事件详情 const { data: events = [], isLoading: isLoadingEventDetails, error: eventDetailsError, } = useEvents(eventIds); // 查找当前 activity const activity = activityId ? (activities.find((a) => a.id === activityId) ?? null) : null; // 构建带事件的 activity const activityWithEvents: ActivityWithEvents | null = activity ? { ...activity, eventIds, events, } : null; return { activity: activityWithEvents, events, isLoading: isLoadingEvents || isLoadingEventDetails, error: eventsError || eventDetailsError, }; } ================================================ FILE: free-todo-frontend/lib/query/automation.ts ================================================ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { customFetcher } from "@/lib/api/fetcher"; import { queryKeys } from "@/lib/query/keys"; import type { AutomationTask, AutomationTaskCreateInput, AutomationTaskListResponse, AutomationTaskUpdateInput, } from "@/lib/types"; export const useAutomationTasks = () => useQuery({ queryKey: queryKeys.automationTasks.list(), queryFn: () => customFetcher("/api/automation/tasks"), }); export const useCreateAutomationTask = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (input: AutomationTaskCreateInput) => customFetcher("/api/automation/tasks", { method: "POST", data: input, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.automationTasks.all, }); }, }); }; export const useUpdateAutomationTask = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, input, }: { id: number; input: AutomationTaskUpdateInput; }) => customFetcher(`/api/automation/tasks/${id}`, { method: "PUT", data: input, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.automationTasks.all, }); }, }); }; export const useDeleteAutomationTask = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: number) => customFetcher(`/api/automation/tasks/${id}`, { method: "DELETE", }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.automationTasks.all, }); }, }); }; export const useRunAutomationTask = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: number) => customFetcher(`/api/automation/tasks/${id}/run`, { method: "POST", }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.automationTasks.all, }); }, }); }; export const useToggleAutomationTask = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => customFetcher( `/api/automation/tasks/${id}/${enabled ? "resume" : "pause"}`, { method: "POST", }, ), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.automationTasks.all, }); }, }); }; ================================================ FILE: free-todo-frontend/lib/query/chat.ts ================================================ "use client"; import type { ChatHistoryItem, ChatSessionSummary } from "@/lib/api"; import { useGetChatHistoryApiChatHistoryGet } from "@/lib/generated/chat/chat"; import { queryKeys } from "./keys"; // Chat history response type (since API returns unknown, we define it based on usage) interface ChatHistoryResponse { sessions?: Array<{ id: string; [key: string]: unknown }>; history?: Array<{ [key: string]: unknown }>; } // ============================================================================ // Query Hooks // ============================================================================ /** * 将会话数据转换为 ChatSessionSummary 类型 * fetcher 已自动将 snake_case 转换为 camelCase */ function mapSessionToSummary(session: { [key: string]: unknown; }): ChatSessionSummary { return { sessionId: typeof session.sessionId === "string" ? session.sessionId : "", title: typeof session.title === "string" ? session.title : undefined, lastActive: typeof session.lastActive === "string" ? session.lastActive : undefined, messageCount: typeof session.messageCount === "number" ? session.messageCount : undefined, chatType: typeof session.chatType === "string" ? session.chatType : undefined, }; } /** * 获取聊天会话列表的 Query Hook * 使用 Orval 生成的 hook */ export function useChatSessions(options?: { limit?: number; chatType?: string; enabled?: boolean; }) { const { chatType, enabled = true } = options ?? {}; return useGetChatHistoryApiChatHistoryGet( { chat_type: chatType, // session_id 不传,获取会话列表 }, { query: { queryKey: queryKeys.chatHistory.sessions(chatType), enabled, staleTime: 30 * 1000, select: (data: unknown) => { // 返回会话列表,转换为 ChatSessionSummary[] const response = data as ChatHistoryResponse; return (response?.sessions ?? []).map(mapSessionToSummary); }, }, }, ); } /** * 将历史记录数据转换为 ChatHistoryItem 类型 */ function mapHistoryItem(item: { [key: string]: unknown; }): ChatHistoryItem | null { if ( typeof item.role !== "string" || (item.role !== "user" && item.role !== "assistant") || typeof item.content !== "string" ) { return null; } return { role: item.role as "user" | "assistant", content: item.content, timestamp: typeof item.timestamp === "string" ? item.timestamp : undefined, extraData: typeof item.extraData === "string" ? item.extraData : undefined, }; } /** * 获取单个会话的消息历史的 Query Hook * 使用 Orval 生成的 hook */ export function useChatHistory( sessionId: string | null, options?: { limit?: number; enabled?: boolean }, ) { const { enabled = true } = options ?? {}; return useGetChatHistoryApiChatHistoryGet( { session_id: sessionId ?? undefined, }, { query: { queryKey: queryKeys.chatHistory.session(sessionId ?? ""), enabled: enabled && sessionId !== null, staleTime: 30 * 1000, select: (data: unknown) => { // 返回消息历史,转换为 ChatHistoryItem[] const response = data as ChatHistoryResponse; return (response?.history ?? []) .map(mapHistoryItem) .filter((item): item is ChatHistoryItem => item !== null); }, }, }, ); } ================================================ FILE: free-todo-frontend/lib/query/config.ts ================================================ "use client"; import { useQueryClient } from "@tanstack/react-query"; import { useGetConfigDetailedApiGetConfigGet, useGetLlmStatusApiLlmStatusGet, useSaveConfigApiSaveConfigPost, } from "@/lib/generated/config/config"; import { queryKeys } from "./keys"; // ============================================================================ // LLM 状态检查 // ============================================================================ interface LlmStatusResponse { configured: boolean; } /** * 检查 LLM 是否已配置的 Query Hook * 用于应用启动时检查配置状态 * 使用 Orval 生成的 hook */ export function useLlmStatus() { return useGetLlmStatusApiLlmStatusGet({ query: { queryKey: ["llm-status"], staleTime: 60 * 1000, // 1 分钟 retry: 1, // 只重试一次 select: (data: unknown) => data as LlmStatusResponse, }, }); } // ============================================================================ // 类型定义 // ============================================================================ export interface AppConfig { // 现有配置 jobsAutoTodoDetectionEnabled?: boolean; // 自动待办检测白名单配置 jobsAutoTodoDetectionParamsWhitelistApps?: string[]; // LLM 配置 llmApiKey?: string; llmBaseUrl?: string; llmModel?: string; llmTemperature?: number; llmMaxTokens?: number; // 录制配置 jobsRecorderEnabled?: boolean; jobsRecorderInterval?: number; jobsRecorderParamsBlacklistEnabled?: boolean; jobsRecorderParamsBlacklistApps?: string[]; [key: string]: unknown; } // ============================================================================ // Query Hooks // ============================================================================ /** * 获取应用配置的 Query Hook * 使用 Orval 生成的 hook,保持相同的 API */ export function useConfig() { return useGetConfigDetailedApiGetConfigGet({ query: { queryKey: queryKeys.config, staleTime: 60 * 1000, // 1 分钟 select: (data: unknown) => { // 处理响应格式:{ success: boolean, config?: Record } const response = data as { success?: boolean; config?: AppConfig; error?: string; }; if (response?.success && response?.config) { return response.config; } throw new Error(response?.error || "Failed to load config"); }, }, }); } // ============================================================================ // Mutation Hooks // ============================================================================ /** * 保存应用配置的 Mutation Hook * 使用 Orval 生成的 hook,添加乐观更新逻辑 */ export function useSaveConfig() { const queryClient = useQueryClient(); return useSaveConfigApiSaveConfigPost({ mutation: { onMutate: async (variables) => { const newConfig = variables.data; // 取消正在进行的查询 await queryClient.cancelQueries({ queryKey: queryKeys.config }); // 保存之前的数据 const previousConfig = queryClient.getQueryData( queryKeys.config, ); // 乐观更新 queryClient.setQueryData(queryKeys.config, (old) => ({ ...old, ...newConfig, })); return { previousConfig }; }, onError: (_err, _variables, context) => { // 发生错误时回滚 if (context?.previousConfig) { queryClient.setQueryData(queryKeys.config, context.previousConfig); } }, onSettled: () => { // 重新获取最新数据 queryClient.invalidateQueries({ queryKey: queryKeys.config }); }, }, }); } // ============================================================================ // 组合 Hook // ============================================================================ /** * 提供配置的读写操作 */ export function useConfigMutations() { const saveConfigMutation = useSaveConfig(); return { saveConfig: (config: Partial) => saveConfigMutation.mutateAsync({ data: config }), isSaving: saveConfigMutation.isPending, saveError: saveConfigMutation.error, }; } ================================================ FILE: free-todo-frontend/lib/query/cost.ts ================================================ "use client"; import { useGetCostConfigApiCostTrackingConfigGet, useGetCostStatsApiCostTrackingStatsGet, } from "@/lib/generated/cost-tracking/cost-tracking"; import { queryKeys } from "./keys"; // Cost stats response type (since API returns unknown, we define it based on usage) // Note: fetcher converts snake_case to camelCase, so we use camelCase here interface CostStatsResponse { data?: { totalCost?: number; totalTokens?: number; totalRequests?: number; dailyCosts?: Record; featureCosts?: Record< string, { inputTokens?: number; outputTokens?: number; requests?: number; cost?: number; } >; modelCosts?: Record< string, { inputTokens?: number; outputTokens?: number; inputCost?: number; outputCost?: number; totalCost?: number; } >; }; } interface CostConfigResponse { data?: Record; } // ============================================================================ // Query Hooks // ============================================================================ /** * 获取费用统计数据的 Query Hook * 使用 Orval 生成的 hook */ export function useCostStats(days: number) { return useGetCostStatsApiCostTrackingStatsGet( { days: days }, { query: { queryKey: queryKeys.costStats(days), staleTime: 60 * 1000, // 1 分钟内数据被认为是新鲜的 select: (data: unknown) => { // 处理响应格式:{ success: boolean, data?: CostStats } const response = data as CostStatsResponse; if (response?.data) { return response.data; } throw new Error("Failed to load cost stats"); }, }, }, ); } /** * 获取费用配置的 Query Hook * 使用 Orval 生成的 hook */ export function useCostConfig() { return useGetCostConfigApiCostTrackingConfigGet({ query: { queryKey: ["costConfig"], staleTime: 5 * 60 * 1000, // 5 分钟 select: (data: unknown) => { // 处理响应格式:{ success: boolean, data?: {...} } const response = data as CostConfigResponse; if (response?.data) { return response.data; } throw new Error("Failed to load cost config"); }, }, }); } ================================================ FILE: free-todo-frontend/lib/query/index.ts ================================================ /** * TanStack Query Hooks 统一导出 */ // Activity Hooks export { useActivities, useActivityEvents, useActivityWithEvents, useEvent, useEvents, useEventsList, } from "./activities"; // Automation Hooks export { useAutomationTasks, useCreateAutomationTask, useDeleteAutomationTask, useRunAutomationTask, useToggleAutomationTask, useUpdateAutomationTask, } from "./automation"; // Chat Hooks export { useChatHistory, useChatSessions } from "./chat"; // Config Hooks export { type AppConfig, useConfig, useConfigMutations, useLlmStatus, useSaveConfig, } from "./config"; // Cost Hooks export { useCostConfig, useCostStats } from "./cost"; // Journal Hooks export { type JournalAutoLinkResult, type JournalView, useJournalMutations, useJournals, } from "./journals"; // Query Keys export { type QueryKeys, queryKeys } from "./keys"; // Provider export { getQueryClient, QueryProvider } from "./provider"; // Todo Hooks export { type ReorderTodoItem, useCreateTodo, useDeleteTodo, useReorderTodos, useTodoMutations, useTodos, useToggleTodoStatus, useUpdateTodo, } from "./todos"; ================================================ FILE: free-todo-frontend/lib/query/journals.ts ================================================ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { unwrapApiData } from "@/lib/api/fetcher"; import { autoLinkJournalApiJournalsAutoLinkPost, createJournalApiJournalsPost, generateAiJournalApiJournalsGenerateAiPost, generateObjectiveJournalApiJournalsGenerateObjectivePost, updateJournalApiJournalsJournalIdPut, useListJournalsApiJournalsGet, } from "@/lib/generated/journals/journals"; import type { JournalAutoLinkRequest, JournalAutoLinkResponse, JournalCreate, JournalGenerateRequest, JournalGenerateResponse, JournalListResponse, JournalResponse, JournalUpdate, ListJournalsApiJournalsGetParams, } from "@/lib/generated/schemas"; import { queryKeys } from "./keys"; interface UseJournalsParams { limit?: number; offset?: number; startDate?: string; endDate?: string; } const normalizeTags = ( raw: Record[], ): { id: number; tagName: string }[] => raw .filter((tag) => typeof tag === "object" && tag !== null) .map((tag) => ({ id: (tag.id as number) ?? 0, tagName: (tag.tagName as string) ?? "", })); const normalizeJournal = (raw: Record) => ({ id: raw.id as number, uid: (raw.uid as string) ?? null, name: (raw.name as string) ?? "", userNotes: (raw.userNotes as string) ?? "", date: raw.date as string, contentFormat: (raw.contentFormat as string) ?? "markdown", contentObjective: (raw.contentObjective as string) ?? null, contentAi: (raw.contentAi as string) ?? null, mood: (raw.mood as string) ?? null, energy: (raw.energy as number) ?? null, dayBucketStart: (raw.dayBucketStart as string) ?? null, createdAt: raw.createdAt as string, updatedAt: raw.updatedAt as string, deletedAt: (raw.deletedAt as string) ?? null, tags: normalizeTags( ((raw.tags as Record[]) ?? []).filter(Boolean), ), relatedTodoIds: (raw.relatedTodoIds as number[]) ?? [], relatedActivityIds: (raw.relatedActivityIds as number[]) ?? [], }); const normalizeAutoLinkResponse = (raw: Record) => ({ relatedTodoIds: (raw.relatedTodoIds as number[]) ?? [], relatedActivityIds: (raw.relatedActivityIds as number[]) ?? [], todoCandidates: (raw.todoCandidates as Array>) ?? [], activityCandidates: (raw.activityCandidates as Array>) ?? [], }); export type JournalView = ReturnType; export type JournalAutoLinkResult = ReturnType; export function useJournals(params?: UseJournalsParams) { const queryParams: ListJournalsApiJournalsGetParams = { limit: params?.limit ?? 50, offset: params?.offset ?? 0, start_date: params?.startDate, end_date: params?.endDate, }; return useListJournalsApiJournalsGet(queryParams, { query: { queryKey: queryKeys.journals.list(params), staleTime: 30 * 1000, select: (data: unknown) => { const response = unwrapApiData(data) ?? { total: 0, journals: [], }; const journals = (response.journals ?? []).map((journal) => normalizeJournal(journal as unknown as Record), ); return { total: response.total ?? 0, journals, }; }, }, }); } const createJournal = async (input: JournalCreate) => { const response = await createJournalApiJournalsPost(input); const data = unwrapApiData(response); return data ? normalizeJournal(data as unknown as Record) : null; }; const updateJournal = async (id: number, input: JournalUpdate) => { const response = await updateJournalApiJournalsJournalIdPut(id, input); const data = unwrapApiData(response); return data ? normalizeJournal(data as unknown as Record) : null; }; const autoLinkJournal = async (input: JournalAutoLinkRequest) => { const response = await autoLinkJournalApiJournalsAutoLinkPost(input); const data = unwrapApiData(response); return data ? normalizeAutoLinkResponse(data as Record) : normalizeAutoLinkResponse({}); }; const generateObjective = async (input: JournalGenerateRequest) => { const response = await generateObjectiveJournalApiJournalsGenerateObjectivePost( input, ); const data = unwrapApiData(response); return data ?? { content: "" }; }; const generateAiView = async (input: JournalGenerateRequest) => { const response = await generateAiJournalApiJournalsGenerateAiPost(input); const data = unwrapApiData(response); return data ?? { content: "" }; }; export function useJournalMutations() { const queryClient = useQueryClient(); const createMutation = useMutation({ mutationFn: createJournal, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.journals.all }); }, }); const updateMutation = useMutation({ mutationFn: ({ id, input }: { id: number; input: JournalUpdate }) => updateJournal(id, input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.journals.all }); }, }); const autoLinkMutation = useMutation({ mutationFn: autoLinkJournal, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.journals.all }); }, }); const objectiveMutation = useMutation({ mutationFn: generateObjective, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.journals.all }); }, }); const aiMutation = useMutation({ mutationFn: generateAiView, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.journals.all }); }, }); return { createJournal: createMutation.mutateAsync, updateJournal: (id: number, input: JournalUpdate) => updateMutation.mutateAsync({ id, input }), autoLinkJournal: autoLinkMutation.mutateAsync, generateObjective: objectiveMutation.mutateAsync, generateAiView: aiMutation.mutateAsync, isCreating: createMutation.isPending, isUpdating: updateMutation.isPending, isAutoLinking: autoLinkMutation.isPending, isGeneratingObjective: objectiveMutation.isPending, isGeneratingAi: aiMutation.isPending, createError: createMutation.error, updateError: updateMutation.error, }; } ================================================ FILE: free-todo-frontend/lib/query/keys.ts ================================================ /** * TanStack Query Keys 常量定义 * 统一管理所有查询的缓存键,确保类型安全和一致性 */ export const queryKeys = { /** * Todo 相关查询键 */ todos: { /** 所有 todo 相关查询的根键 */ all: ["todos"] as const, /** todo 列表查询 */ list: (params?: { status?: string; limit?: number; offset?: number }) => ["todos", "list", params] as const, /** 单个 todo 详情 */ detail: (id: string) => ["todos", "detail", id] as const, }, /** * Activity 相关查询键 */ activities: { /** 所有 activity 相关查询的根键 */ all: ["activities"] as const, /** activity 列表查询 */ list: (params?: { limit?: number; offset?: number; start_date?: string; end_date?: string; }) => ["activities", "list", params] as const, /** 单个 activity 的事件列表 */ events: (activityId: number) => ["activities", activityId, "events"] as const, }, /** * Event 相关查询键 */ events: { /** 所有 event 相关查询的根键 */ all: ["events"] as const, /** 单个 event 详情 */ detail: (id: number) => ["events", id] as const, /** event 列表查询 */ list: (params?: { limit?: number; offset?: number; start_date?: string; end_date?: string; app_name?: string; }) => ["events", "list", params] as const, }, /** * Cost 统计查询键 */ costStats: (days: number) => ["costStats", days] as const, /** * Journal 相关查询键 */ journals: { /** 所有 journal 相关查询的根键 */ all: ["journals"] as const, /** journal 列表查询 */ list: (params?: { limit?: number; offset?: number; startDate?: string; endDate?: string; }) => ["journals", "list", params] as const, /** 单个 journal 详情 */ detail: (id: number) => ["journals", "detail", id] as const, }, /** * 配置相关查询键 */ config: ["config"] as const, /** * Chat 历史记录查询键 */ chatHistory: { /** 所有 chat 相关查询的根键 */ all: ["chatHistory"] as const, /** 会话列表 */ sessions: (chatType?: string) => ["chatHistory", "sessions", chatType] as const, /** 单个会话的消息历史 */ session: (sessionId: string) => ["chatHistory", "session", sessionId] as const, }, /** * 自动化任务查询键 */ automationTasks: { all: ["automationTasks"] as const, list: () => ["automationTasks", "list"] as const, }, } as const; /** * 类型导出:用于类型推断 */ export type QueryKeys = typeof queryKeys; ================================================ FILE: free-todo-frontend/lib/query/provider.tsx ================================================ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode, useState } from "react"; /** * 创建 QueryClient 实例的工厂函数 * 使用工厂函数确保每个客户端都有独立的 QueryClient 实例 */ function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { // 30 秒内数据被认为是新鲜的,不会重新请求 staleTime: 30 * 1000, // 数据在缓存中保留 5 分钟 gcTime: 5 * 60 * 1000, // 窗口聚焦时重新获取数据 refetchOnWindowFocus: true, // 网络重连时重新获取数据 refetchOnReconnect: true, // 组件挂载时如果数据过期则重新获取 refetchOnMount: true, // 失败后重试 1 次 retry: 1, // 重试延迟 retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }, mutations: { // mutation 失败后不自动重试 retry: false, }, }, }); } // 浏览器端的单例 QueryClient let browserQueryClient: QueryClient | undefined; /** * 获取 QueryClient 实例 * - 服务端:每次创建新实例 * - 客户端:使用单例模式 * * 导出此函数以便在非 React 代码中使用(如 Zustand stores) */ export function getQueryClient() { if (typeof window === "undefined") { // 服务端:总是创建新的 QueryClient return makeQueryClient(); } // 客户端:使用单例 if (!browserQueryClient) { browserQueryClient = makeQueryClient(); } return browserQueryClient; } interface QueryProviderProps { children: ReactNode; } /** * TanStack Query Provider 组件 * 为整个应用提供 QueryClient 上下文 */ export function QueryProvider({ children }: QueryProviderProps) { // 使用 useState 确保在 SSR 和 CSR 之间保持一致 const [queryClient] = useState(() => getQueryClient()); return ( {children} ); } ================================================ FILE: free-todo-frontend/lib/query/todos.ts ================================================ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createTodoApiTodosPost, deleteTodoApiTodosTodoIdDelete, reorderTodosApiTodosReorderPost, updateTodoApiTodosTodoIdPut, useListTodosApiTodosGet, } from "@/lib/generated/todos/todos"; import type { CreateTodoInput, Todo, TodoListResponse, TodoPriority, TodoStatus, UpdateTodoInput, } from "@/lib/types"; import { queryKeys } from "./keys"; // ============================================================================ // Helper Functions // ============================================================================ const normalizePriority = (priority: unknown): TodoPriority => { if (priority === "high" || priority === "medium" || priority === "low") { return priority; } return "none"; }; const normalizeStatus = (status: unknown): TodoStatus => { if (status === "completed" || status === "canceled" || status === "draft") return status; return "active"; }; function normalizeDateTimeValue( value?: string | null, ): string | null | undefined { // undefined 表示不更新;null 表示显式清空 if (value === undefined) return undefined; if (value === null) return null; // 兼容 的 YYYY-MM-DD(后端期望 datetime) if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { return `${value}T00:00:00`; } return value; } /** * Normalize API response to ensure consistent Todo type * Now that fetcher auto-converts snake_case -> camelCase, we just need to normalize some optional fields */ function normalizeTodo(raw: Record): Todo { return { id: raw.id as number, name: raw.name as string, summary: (raw.summary as string) ?? undefined, description: (raw.description as string) ?? undefined, userNotes: (raw.userNotes as string) ?? undefined, status: normalizeStatus(raw.status), priority: normalizePriority(raw.priority), itemType: (raw.itemType as string) ?? undefined, location: (raw.location as string) ?? undefined, categories: (raw.categories as string) ?? undefined, classification: (raw.classification as string) ?? undefined, deadline: (raw.deadline as string) ?? undefined, startTime: (raw.startTime as string) ?? undefined, endTime: (raw.endTime as string) ?? undefined, dtstart: (raw.dtstart as string) ?? undefined, dtend: (raw.dtend as string) ?? undefined, due: (raw.due as string) ?? undefined, duration: (raw.duration as string) ?? undefined, timeZone: (raw.timeZone as string) ?? undefined, tzid: (raw.tzid as string) ?? undefined, isAllDay: (raw.isAllDay as boolean) ?? undefined, dtstamp: (raw.dtstamp as string) ?? undefined, created: (raw.created as string) ?? undefined, lastModified: (raw.lastModified as string) ?? undefined, sequence: (raw.sequence as number) ?? undefined, rdate: (raw.rdate as string) ?? undefined, exdate: (raw.exdate as string) ?? undefined, recurrenceId: (raw.recurrenceId as string) ?? undefined, relatedToUid: (raw.relatedToUid as string) ?? undefined, relatedToReltype: (raw.relatedToReltype as string) ?? undefined, icalStatus: (raw.icalStatus as string) ?? undefined, reminderOffsets: (raw.reminderOffsets as number[] | null) ?? undefined, rrule: (raw.rrule as string | null) ?? undefined, order: (raw.order as number) ?? 0, tags: (raw.tags as string[]) ?? [], attachments: (raw.attachments as Todo["attachments"]) ?? [], parentTodoId: raw.parentTodoId === null || raw.parentTodoId === undefined ? null : (raw.parentTodoId as number), relatedActivities: (raw.relatedActivities as number[]) ?? [], completedAt: (raw.completedAt as string) ?? undefined, percentComplete: (raw.percentComplete as number) ?? undefined, createdAt: raw.createdAt as string, updatedAt: raw.updatedAt as string, }; } // ============================================================================ // Query Hooks // ============================================================================ interface UseTodosParams { status?: string; limit?: number; offset?: number; } /** * 获取 Todo 列表的 Query Hook * 使用 Orval 生成的 hook */ export function useTodos(params?: UseTodosParams) { return useListTodosApiTodosGet( { limit: params?.limit ?? 2000, offset: params?.offset ?? 0, status: params?.status, }, { query: { queryKey: queryKeys.todos.list(params), staleTime: 30 * 1000, // 30 秒内数据被认为是新鲜的 select: (data: unknown) => { // Data is now auto-converted to camelCase by the fetcher const response = data as TodoListResponse; const todos = response?.todos ?? []; return todos.map((raw) => normalizeTodo(raw as unknown as Record), ); }, }, }, ); } // ============================================================================ // Mutation Hooks // ============================================================================ // 防抖更新相关的全局状态 const pendingUpdateTimers = new Map>(); const pendingUpdatePayloads = new Map(); /** * 创建 Todo 的 Mutation Hook */ export function useCreateTodo() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: CreateTodoInput) => { // Fetcher will auto-convert camelCase -> snake_case for request // and snake_case -> camelCase for response const payload = { name: input.name, summary: input.summary, description: input.description, userNotes: input.userNotes, parentTodoId: input.parentTodoId ?? null, itemType: input.itemType, location: input.location, categories: input.categories, classification: input.classification, deadline: normalizeDateTimeValue(input.deadline), startTime: normalizeDateTimeValue(input.startTime), endTime: normalizeDateTimeValue(input.endTime), dtstart: normalizeDateTimeValue(input.dtstart), dtend: normalizeDateTimeValue(input.dtend), due: normalizeDateTimeValue(input.due), duration: input.duration, timeZone: input.timeZone, tzid: input.tzid, isAllDay: input.isAllDay, dtstamp: normalizeDateTimeValue(input.dtstamp), created: normalizeDateTimeValue(input.created), lastModified: normalizeDateTimeValue(input.lastModified), sequence: input.sequence, rdate: input.rdate, exdate: input.exdate, recurrenceId: normalizeDateTimeValue(input.recurrenceId), relatedToUid: input.relatedToUid, relatedToReltype: input.relatedToReltype, icalStatus: input.icalStatus, reminderOffsets: input.reminderOffsets, rrule: input.rrule, status: input.status ?? "active", priority: input.priority ?? "none", completedAt: normalizeDateTimeValue(input.completedAt), percentComplete: input.percentComplete, order: input.order ?? 0, tags: input.tags ?? [], relatedActivities: input.relatedActivities ?? [], }; const created = await createTodoApiTodosPost(payload as never); return normalizeTodo(created as unknown as Record); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); }, }); } interface UpdateTodoParams { id: number; input: UpdateTodoInput; } /** * 更新 Todo 的 Mutation Hook * 支持乐观更新和防抖(针对描述和备注字段) */ export function useUpdateTodo() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id, input }: UpdateTodoParams) => { const keys = Object.keys(input); const shouldDebounce = keys.length > 0 && keys.every((k) => k === "description" || k === "userNotes"); // 合并同一 todo 的待发送 payload const merged: UpdateTodoInput = { ...(pendingUpdatePayloads.get(id) ?? {}), ...input, }; pendingUpdatePayloads.set(id, merged); // 如果需要防抖,返回一个 Promise 延迟执行 if (shouldDebounce) { return new Promise((resolve, reject) => { const existingTimer = pendingUpdateTimers.get(id); if (existingTimer) clearTimeout(existingTimer); const timer = setTimeout(async () => { pendingUpdateTimers.delete(id); const body = pendingUpdatePayloads.get(id); pendingUpdatePayloads.delete(id); if (!body || Object.keys(body).length === 0) { const cachedData = queryClient.getQueryData( queryKeys.todos.list(), ); const todos = cachedData?.todos ?? []; const todo = todos.find((t) => t.id === id); if (todo) { resolve(todo); } else { reject(new Error("Todo not found")); } return; } try { // Build payload with normalized date/time inputs const payload = { ...body, deadline: normalizeDateTimeValue(body.deadline), startTime: normalizeDateTimeValue(body.startTime), endTime: normalizeDateTimeValue(body.endTime), dtstart: normalizeDateTimeValue(body.dtstart), dtend: normalizeDateTimeValue(body.dtend), due: normalizeDateTimeValue(body.due), dtstamp: normalizeDateTimeValue(body.dtstamp), created: normalizeDateTimeValue(body.created), lastModified: normalizeDateTimeValue(body.lastModified), recurrenceId: normalizeDateTimeValue(body.recurrenceId), completedAt: normalizeDateTimeValue(body.completedAt), rrule: body.rrule, }; const updated = await updateTodoApiTodosTodoIdPut( id, payload as never, ); resolve( normalizeTodo(updated as unknown as Record), ); } catch (err) { reject(err); } }, 500); pendingUpdateTimers.set(id, timer); }); } // 非防抖字段立即更新 const body = pendingUpdatePayloads.get(id); pendingUpdatePayloads.delete(id); if (!body || Object.keys(body).length === 0) { throw new Error("No fields to update"); } // Build payload with normalized date/time inputs const payload = { ...body, deadline: normalizeDateTimeValue(body.deadline), startTime: normalizeDateTimeValue(body.startTime), endTime: normalizeDateTimeValue(body.endTime), dtstart: normalizeDateTimeValue(body.dtstart), dtend: normalizeDateTimeValue(body.dtend), due: normalizeDateTimeValue(body.due), dtstamp: normalizeDateTimeValue(body.dtstamp), created: normalizeDateTimeValue(body.created), lastModified: normalizeDateTimeValue(body.lastModified), recurrenceId: normalizeDateTimeValue(body.recurrenceId), completedAt: normalizeDateTimeValue(body.completedAt), rrule: body.rrule, }; const updated = await updateTodoApiTodosTodoIdPut(id, payload as never); return normalizeTodo(updated as unknown as Record); }, onMutate: async ({ id, input }) => { await queryClient.cancelQueries({ queryKey: queryKeys.todos.all }); const previousData = queryClient.getQueryData( queryKeys.todos.list(), ); // 乐观更新 queryClient.setQueryData( queryKeys.todos.list(), (old: TodoListResponse | undefined) => { if (!old || !old.todos) return old; const updatedTodos = old.todos.map((todo) => { if (todo.id === id) { return { ...todo, ...input, priority: normalizePriority(input.priority ?? todo.priority), status: normalizeStatus(input.status ?? todo.status), updatedAt: new Date().toISOString(), }; } return todo; }); return { ...old, todos: updatedTodos }; }, ); return { previousData }; }, onError: (_err, _variables, context) => { if (context?.previousData) { queryClient.setQueryData(queryKeys.todos.list(), context.previousData); } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); }, }); } /** * 删除 Todo 的 Mutation Hook */ export function useDeleteTodo() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id: number) => { await deleteTodoApiTodosTodoIdDelete(id); return id; }, onMutate: async (id) => { await queryClient.cancelQueries({ queryKey: queryKeys.todos.all }); const previousData = queryClient.getQueryData( queryKeys.todos.list(), ); const previousTodos = previousData?.todos ?? []; // 递归查找所有子任务 ID const findAllChildIds = ( parentId: number, allTodos: Todo[], ): number[] => { const childIds: number[] = []; const children = allTodos.filter((t) => t.parentTodoId === parentId); for (const child of children) { childIds.push(child.id); childIds.push(...findAllChildIds(child.id, allTodos)); } return childIds; }; const allIdsToDelete = [id, ...findAllChildIds(id, previousTodos)]; const idsToDeleteSet = new Set(allIdsToDelete); // 乐观更新 queryClient.setQueryData( queryKeys.todos.list(), (old: TodoListResponse | undefined) => { if (!old || !old.todos) return old; const updatedTodos = old.todos.filter( (todo) => !idsToDeleteSet.has(todo.id), ); return { ...old, todos: updatedTodos, total: updatedTodos.length, }; }, ); return { previousData, deletedIds: allIdsToDelete }; }, onError: (_err, _id, context) => { if (context?.previousData) { queryClient.setQueryData(queryKeys.todos.list(), context.previousData); } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); }, }); } /** * 切换 Todo 状态的 Mutation Hook */ export function useToggleTodoStatus() { const queryClient = useQueryClient(); const updateMutation = useUpdateTodo(); return useMutation({ mutationFn: async (id: number) => { const cachedData = queryClient.getQueryData( queryKeys.todos.list(), ); const todos = cachedData?.todos ?? []; const todo = todos.find((t) => t.id === id); if (!todo) throw new Error("Todo not found"); const nextStatus: TodoStatus = todo.status === "completed" ? "active" : todo.status === "canceled" ? "canceled" : todo.status === "draft" ? "active" : "completed"; return updateMutation.mutateAsync({ id, input: { status: nextStatus } }); }, }); } /** * 重排序参数 */ export interface ReorderTodoItem { id: number; order: number; parentTodoId?: number | null; } /** * 批量重排序 Todo 的 Mutation Hook */ export function useReorderTodos() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (items: ReorderTodoItem[]) => { // Fetcher will auto-convert camelCase -> snake_case return reorderTodosApiTodosReorderPost({ items } as never); }, onMutate: async (items) => { await queryClient.cancelQueries({ queryKey: queryKeys.todos.all }); const previousData = queryClient.getQueryData( queryKeys.todos.list(), ); // 乐观更新 queryClient.setQueryData( queryKeys.todos.list(), (old: TodoListResponse | undefined) => { if (!old || !old.todos) return old; const updatedTodos = old.todos.map((todo) => { const item = items.find((i) => i.id === todo.id); if (item) { return { ...todo, order: item.order, ...(item.parentTodoId !== undefined ? { parentTodoId: item.parentTodoId } : {}), updatedAt: new Date().toISOString(), }; } return todo; }); return { ...old, todos: updatedTodos }; }, ); return { previousData }; }, onError: (_err, _variables, context) => { if (context?.previousData) { queryClient.setQueryData(queryKeys.todos.list(), context.previousData); } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); }, }); } // ============================================================================ // 组合 Hook:提供完整的 Todo 操作能力 // ============================================================================ /** * 提供所有 Todo Mutation 操作的组合 Hook */ export function useTodoMutations() { const createMutation = useCreateTodo(); const updateMutation = useUpdateTodo(); const deleteMutation = useDeleteTodo(); const toggleStatusMutation = useToggleTodoStatus(); const reorderMutation = useReorderTodos(); return { createTodo: createMutation.mutateAsync, updateTodo: (id: number, input: UpdateTodoInput) => updateMutation.mutateAsync({ id, input }), deleteTodo: deleteMutation.mutateAsync, toggleTodoStatus: toggleStatusMutation.mutateAsync, reorderTodos: reorderMutation.mutateAsync, isCreating: createMutation.isPending, isUpdating: updateMutation.isPending, isDeleting: deleteMutation.isPending, isReordering: reorderMutation.isPending, createError: createMutation.error, updateError: updateMutation.error, deleteError: deleteMutation.error, reorderError: reorderMutation.error, }; } ================================================ FILE: free-todo-frontend/lib/reminders.ts ================================================ export const REMINDER_PRESET_MINUTES = [0, 5, 10, 30, 60, 1440]; export type ReminderUnit = "minutes" | "hours" | "days"; export const sanitizeReminderOffsets = (value: number[]): number[] => { const cleaned = value .map((item) => Number(item)) .filter((item) => Number.isFinite(item) && item >= 0); return Array.from(new Set(cleaned)).sort((a, b) => a - b); }; export const normalizeReminderOffsets = ( value: number[] | null | undefined, fallback: number[] = [], ): number[] => { if (value === null || value === undefined) { return [...fallback]; } return sanitizeReminderOffsets(value); }; export const formatReminderOffset = ( t: (key: string, values?: Record) => string, minutes: number, ): string => { if (minutes === 0) return t("atTime"); if (minutes < 60) return t("minutesBefore", { count: minutes }); if (minutes % 1440 === 0) { return t("daysBefore", { count: minutes / 1440 }); } if (minutes % 60 === 0) { return t("hoursBefore", { count: minutes / 60 }); } return t("minutesBefore", { count: minutes }); }; export const formatReminderSummary = ( t: (key: string, values?: Record) => string, offsets: number[], emptyLabel: string, ): string => { if (!offsets.length) return emptyLabel; return offsets.map((offset) => formatReminderOffset(t, offset)).join(", "); }; ================================================ FILE: free-todo-frontend/lib/services/notification-poller.ts ================================================ import { unwrapApiData } from "@/lib/api/fetcher"; import { getNotificationApiNotificationsGet } from "@/lib/generated/notifications/notifications"; import { listTodosApiTodosGet } from "@/lib/generated/todos/todos"; import type { Notification, PollingEndpoint, } from "@/lib/store/notification-store"; import { useNotificationStore } from "@/lib/store/notification-store"; import type { TodoListResponse } from "@/lib/types"; // 通知响应类型(后端 OpenAPI 未定义响应 schema,手动定义) interface NotificationResponse { id?: string; title: string; content: string; timestamp?: string; todoId?: number; } class NotificationPoller { private timers: Map = new Map(); private isPageVisible: boolean = true; constructor() { // 监听页面可见性变化 if (typeof document !== "undefined") { document.addEventListener("visibilitychange", () => { this.isPageVisible = !document.hidden; if (this.isPageVisible) { // 页面可见时恢复所有轮询 this.resumeAll(); } else { // 页面隐藏时暂停所有轮询 this.pauseAll(); } }); } } /** * 注册并启动轮询端点 */ registerEndpoint(endpoint: PollingEndpoint): void { // 如果已存在,先停止旧的 this.unregisterEndpoint(endpoint.id); if (!endpoint.enabled) { return; } // 立即执行一次 this.pollEndpoint(endpoint); // 设置定时器 const timer = setInterval(() => { if (this.isPageVisible) { this.pollEndpoint(endpoint); } }, endpoint.interval); this.timers.set(endpoint.id, timer); } /** * 注销并停止轮询端点 */ unregisterEndpoint(id: string): void { const timer = this.timers.get(id); if (timer) { clearInterval(timer); this.timers.delete(id); } } /** * 轮询单个端点 */ private async pollEndpoint(endpoint: PollingEndpoint): Promise { try { // 检查是否是 draft todo 端点 if ( endpoint.url.includes("/api/todos") && endpoint.url.includes("status=draft") ) { await this.pollDraftTodos(endpoint); return; } // 标准通知端点 - 使用 Orval 生成的 API const response = await getNotificationApiNotificationsGet(); const notificationData = unwrapApiData< NotificationResponse[] | NotificationResponse | null >(response); const rawList = Array.isArray(notificationData) ? notificationData : notificationData ? [notificationData] : []; const notifications: Notification[] = rawList .filter((item) => item && (item.title || item.content)) .map((item, index) => ({ id: item.id || `${endpoint.id}-${item.timestamp || Date.now()}-${index}`, title: item.title, content: item.content, timestamp: item.timestamp || new Date().toISOString(), source: endpoint.id, todoId: item.todoId, })); const store = useNotificationStore.getState(); store.setNotificationsFromSource(endpoint.id, notifications); } catch (error) { // 静默处理错误,避免频繁失败请求 console.warn(`Failed to poll endpoint ${endpoint.id}:`, error); } } /** * 轮询 draft todo 端点 */ private async pollDraftTodos(endpoint: PollingEndpoint): Promise { try { // 解析 URL 参数 let limit = 1; try { const urlStr = endpoint.url.startsWith("/") ? `http://localhost${endpoint.url}` : endpoint.url; const url = new URL(urlStr); const limitParam = url.searchParams.get("limit"); if (limitParam) { limit = parseInt(limitParam, 10) || 1; } } catch { // URL解析失败,使用默认值 limit = 1; } // 获取 draft todos - 使用 Orval 生成的 API const result = await listTodosApiTodosGet({ status: "draft", limit, offset: 0, }); const data = unwrapApiData(result); const todos = data?.todos ?? []; const store = useNotificationStore.getState(); const current = store.notifications.find( (notification) => notification.source === endpoint.id, ); // 如果当前有 draft todo 通知,检查对应的 todo 是否还存在 if ( current && current.source === endpoint.id && current.todoId !== undefined ) { const todoExists = todos.some((todo) => todo.id === current.todoId); // 如果 todo 不存在了,清除通知 if (!todoExists) { store.removeNotificationsBySource(endpoint.id); store.setExpanded(false); return; } } if (todos.length > 0) { // 取最新的一个 todo const latestTodo = todos[0]; // 转换为通知格式 const notification: Notification = { id: `draft-todo-${latestTodo.id}`, title: "新待办事项待确认", content: latestTodo.name || "待办事项", timestamp: latestTodo.createdAt || new Date().toISOString(), source: endpoint.id, todoId: latestTodo.id, // 添加 todoId 以便后续操作 }; store.setNotificationsFromSource(endpoint.id, [notification]); } else { // 如果没有 draft todos 了,且当前通知是来自这个端点的,清除通知 store.removeNotificationsBySource(endpoint.id); store.setExpanded(false); } } catch (error) { // 静默处理错误,避免频繁失败请求 console.warn(`Failed to poll draft todos from ${endpoint.id}:`, error); } } /** * 暂停所有轮询 */ private pauseAll(): void { // 定时器继续运行,但 pollEndpoint 会检查 isPageVisible // 这样页面重新可见时可以立即恢复 } /** * 恢复所有轮询 */ private resumeAll(): void { // 立即执行一次所有端点的轮询 const store = useNotificationStore.getState(); const endpoints = store.getAllEndpoints(); for (const endpoint of endpoints) { if (endpoint.enabled) { this.pollEndpoint(endpoint); } } } /** * 清理所有轮询定时器 */ cleanup(): void { for (const timer of this.timers.values()) { clearInterval(timer); } this.timers.clear(); } /** * 更新端点配置 */ updateEndpoint(endpoint: PollingEndpoint): void { this.unregisterEndpoint(endpoint.id); if (endpoint.enabled) { this.registerEndpoint(endpoint); } } } // 单例实例 let pollerInstance: NotificationPoller | null = null; export function getNotificationPoller(): NotificationPoller { if (!pollerInstance) { pollerInstance = new NotificationPoller(); } return pollerInstance; } // 清理函数(用于组件卸载时) export function cleanupNotificationPoller(): void { if (pollerInstance) { pollerInstance.cleanup(); pollerInstance = null; } } ================================================ FILE: free-todo-frontend/lib/store/activity-store.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; interface ActivityStoreState { selectedActivityId: number | null; search: string; setSelectedActivityId: (id: number | null) => void; setSearch: (search: string) => void; } export const useActivityStore = create()( persist( (set) => ({ selectedActivityId: null, search: "", setSelectedActivityId: (id) => set({ selectedActivityId: id }), setSearch: (search) => set({ search }), }), { name: "activity-config", storage: createJSONStorage(() => { return { getItem: (name: string): string | null => { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(name); if (!stored) return null; const parsed = JSON.parse(stored); const state = parsed.state || parsed; // 验证 selectedActivityId let selectedActivityId: number | null = null; if ( state.selectedActivityId !== null && state.selectedActivityId !== undefined ) { const id = typeof state.selectedActivityId === "number" ? state.selectedActivityId : Number.parseInt(String(state.selectedActivityId), 10); if (!Number.isNaN(id) && Number.isFinite(id) && id > 0) { selectedActivityId = id; } } // 验证 search const search: string = typeof state.search === "string" ? state.search : ""; return JSON.stringify({ state: { selectedActivityId, search, }, }); } catch (e) { console.error("Error loading activity config:", e); return null; } }, setItem: (name: string, value: string): void => { if (typeof window === "undefined") return; try { localStorage.setItem(name, value); } catch (e) { console.error("Error saving activity config:", e); } }, removeItem: (name: string): void => { if (typeof window === "undefined") return; localStorage.removeItem(name); }, }; }), }, ), ); ================================================ FILE: free-todo-frontend/lib/store/audio-recording-store.ts ================================================ /** * 全局音频录音状态管理 * * 将录音状态和资源提升到全局层面,使录音在面板切换时不会中断。 * 核心思路: * - 使用模块级变量存储不可序列化的资源(WebSocket、AudioContext、MediaStream) * - 使用 Zustand store 存储可序列化的状态(isRecording、transcriptionText 等) * - 组件卸载时不清理录音资源,只有显式调用 stopRecording 才会停止 */ import { create } from "zustand"; // ========== 类型定义 ========== interface TodoItem { title: string; description?: string; deadline?: string; source_text?: string; } interface ScheduleItem { title: string; time?: string; description?: string; source_text?: string; } type TranscriptionCallback = (text: string, isFinal: boolean) => void type RealtimeNlpCallback = (data: { optimizedText?: string; todos?: TodoItem[]; schedules?: ScheduleItem[]; }) => void type ErrorCallback = (error: Error) => void interface AudioRecordingState { /** 是否正在录音 */ isRecording: boolean; /** 录音开始时间(毫秒时间戳) */ recordingStartedAt: number | null; /** 录音开始的 Date 对象(用于时间标签) */ recordingStartedDate: Date | null; /** 上一个 final 文本的时间戳(用于计算段落时间) */ lastFinalEndMs: number | null; // ===== 转录数据(在面板切换时保持) ===== /** 原始转录文本 */ transcriptionText: string; /** 正在识别的部分文本(未确认) */ partialText: string; /** 优化后的文本 */ optimizedText: string; /** 段落时间(秒) */ segmentTimesSec: number[]; /** 段落时间标签 */ segmentTimeLabels: string[]; /** 段落录音 ID */ segmentRecordingIds: number[]; /** 段落偏移(秒) */ segmentOffsetsSec: number[]; /** 实时提取的待办 */ liveTodos: TodoItem[]; /** 实时提取的日程 */ liveSchedules: ScheduleItem[]; } interface AudioRecordingActions { /** 开始录音 */ startRecording: ( onTranscription: TranscriptionCallback, onRealtimeNlp?: RealtimeNlpCallback, onError?: ErrorCallback, is24x7?: boolean, ) => Promise; /** 停止录音 */ stopRecording: (segmentTimestamps?: number[]) => void; /** 重置时间戳引用(用于新段落) */ resetLastFinalEnd: () => void; /** 更新 lastFinalEndMs */ updateLastFinalEnd: (ms: number) => void; // ===== 转录数据更新方法 ===== /** 追加转录文本 */ appendTranscriptionText: (text: string) => void; /** 设置部分文本 */ setPartialText: (text: string) => void; /** 设置优化文本 */ setOptimizedText: (text: string) => void; /** 追加段落数据 */ appendSegmentData: (data: { timeSec: number; timeLabel: string; recordingId: number; offsetSec: number; }) => void; /** 设置实时待办 */ setLiveTodos: (todos: TodoItem[]) => void; /** 设置实时日程 */ setLiveSchedules: (schedules: ScheduleItem[]) => void; /** 清空录音会话数据(开始新录音时调用) */ clearSessionData: () => void; } type AudioRecordingStore = AudioRecordingState & AudioRecordingActions; // ========== 模块级资源存储(不可序列化) ========== let wsRef: WebSocket | null = null; let audioContextRef: AudioContext | null = null; let processorRef: ScriptProcessorNode | null = null; let mediaStreamRef: MediaStream | null = null; // 回调函数引用(用于在 WebSocket 消息中调用) let currentOnTranscription: TranscriptionCallback | null = null; let currentOnRealtimeNlp: RealtimeNlpCallback | null = null; let currentOnError: ErrorCallback | null = null; // ========== 7×24 自动重连相关变量 ========== let reconnectTimeoutRef: ReturnType | null = null; let reconnectAttemptsRef = 0; const maxReconnectAttempts = 5; const reconnectDelayMs = 3000; // 3秒后重连 let shouldReconnectRef = false; // 标记是否应该重连 let currentIs24x7 = false; // 当前是否为 7×24 模式 // ========== 内部辅助函数 ========== /** * 获取 API 基础 URL */ function getApiBaseUrl(): string { return ( process.env.NEXT_PUBLIC_API_URL || (typeof window !== "undefined" && (window as Window & { __BACKEND_URL__?: string }).__BACKEND_URL__) || "http://localhost:8100" ); } /** * 清理录音资源 * @param segmentTimestamps 段落时间戳数组 * @param isReconnecting 是否正在重连(重连时不清理回调) */ function cleanupRecordingResources(segmentTimestamps?: number[], isReconnecting = false): void { // 停止 WebAudio if (processorRef) { try { processorRef.disconnect(); } catch { // ignore } processorRef.onaudioprocess = null; processorRef = null; } if (audioContextRef) { try { audioContextRef.close(); } catch { // ignore } audioContextRef = null; } if (mediaStreamRef) { for (const track of mediaStreamRef.getTracks()) { track.stop(); } mediaStreamRef = null; } if (wsRef) { // 发送停止消息,包含时间戳数组(如果提供) const stopMessage: { type: string; segment_timestamps?: number[] } = { type: "stop", }; if (segmentTimestamps && segmentTimestamps.length > 0) { stopMessage.segment_timestamps = segmentTimestamps; } try { wsRef.send(JSON.stringify(stopMessage)); wsRef.close(); } catch { // ignore } wsRef = null; } // 如果不是重连,清理回调引用和重连状态 if (!isReconnecting) { currentOnTranscription = null; currentOnRealtimeNlp = null; currentOnError = null; // 停止自动重连 shouldReconnectRef = false; if (reconnectTimeoutRef) { clearTimeout(reconnectTimeoutRef); reconnectTimeoutRef = null; } reconnectAttemptsRef = 0; currentIs24x7 = false; } } // ========== Zustand Store ========== export const useAudioRecordingStore = create((set, get) => ({ // ===== 核心状态 ===== isRecording: false, recordingStartedAt: null, recordingStartedDate: null, lastFinalEndMs: null, // ===== 转录数据 ===== transcriptionText: "", partialText: "", optimizedText: "", segmentTimesSec: [], segmentTimeLabels: [], segmentRecordingIds: [], segmentOffsetsSec: [], liveTodos: [], liveSchedules: [], // ===== Actions ===== startRecording: async (onTranscription, onRealtimeNlp, onError, is24x7 = false) => { // 如果已经在录音,直接返回 if (get().isRecording) { console.warn("[AudioRecordingStore] Already recording, ignoring start request"); return; } try { // 设置 7×24 模式标志 currentIs24x7 = is24x7; shouldReconnectRef = is24x7; // 7×24 模式启用自动重连 // 如果是重连成功,重置重连计数 if (reconnectAttemptsRef > 0) { reconnectAttemptsRef = 0; console.log("[AudioRecordingStore] WebSocket 重连成功"); } // 获取麦克风权限 console.log("[AudioRecordingStore] 请求麦克风权限..."); const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); console.log("[AudioRecordingStore] ✅ 麦克风权限已获取"); mediaStreamRef = stream; // 保存回调引用 currentOnTranscription = onTranscription; currentOnRealtimeNlp = onRealtimeNlp || null; currentOnError = onError || null; // 连接到后端 WebSocket const apiBaseUrl = getApiBaseUrl(); const wsUrl = apiBaseUrl.replace("http://", "ws://").replace("https://", "wss://"); const wsEndpoint = `${wsUrl}/api/audio/transcribe`; const ws = new WebSocket(wsEndpoint); ws.binaryType = "arraybuffer"; ws.onopen = () => { // 发送初始化消息 ws.send(JSON.stringify({ is_24x7: is24x7 })); // 使用 WebAudio 直接发送 PCM16(16k) 到后端 type AudioContextCtor = typeof AudioContext & { webkitAudioContext?: typeof AudioContext; }; const AudioCtx = (window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }) .webkitAudioContext) as AudioContextCtor; const audioContext = new AudioCtx({ sampleRate: 16000 }); audioContextRef = audioContext; const source = audioContext.createMediaStreamSource(stream); const processor = audioContext.createScriptProcessor(4096, 1, 1); processorRef = processor; processor.onaudioprocess = (e) => { if (ws.readyState !== WebSocket.OPEN) return; const input = e.inputBuffer.getChannelData(0); // Float32 [-1, 1] // 转 Int16 little-endian const buffer = new ArrayBuffer(input.length * 2); const view = new DataView(buffer); for (let i = 0; i < input.length; i++) { const s = Math.max(-1, Math.min(1, input[i])); view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); } ws.send(buffer); }; source.connect(processor); processor.connect(audioContext.destination); // 记录开始时间并更新状态 const now = Date.now(); set({ isRecording: true, recordingStartedAt: now, recordingStartedDate: new Date(), lastFinalEndMs: null, }); }; ws.onmessage = (event) => { try { if (typeof event.data === "string") { const data = JSON.parse(event.data); // 转录结果 if (data.header?.name === "TranscriptionResultChanged") { const text = data.payload?.result; const isFinal = data.payload?.is_final || false; if (text && currentOnTranscription) { currentOnTranscription(text, isFinal); } return; } // 实时优化文本 if (data.header?.name === "OptimizedTextChanged") { const text = data.payload?.text; if (currentOnRealtimeNlp && typeof text === "string") { currentOnRealtimeNlp({ optimizedText: text }); } return; } // 实时提取结果 if (data.header?.name === "ExtractionChanged") { const todos = data.payload?.todos; const schedules = data.payload?.schedules; if (currentOnRealtimeNlp) { currentOnRealtimeNlp({ todos: Array.isArray(todos) ? todos : [], schedules: Array.isArray(schedules) ? schedules : [], }); } return; } // 分段保存通知(7×24 模式) if (data.header?.name === "SegmentSaved") { // 通知前端分段已保存,需要重置时间戳和文本 // 通过特殊标记传递给 onTranscription,并传递原因 const reason = data.payload?.message || "分段保存"; if (currentOnTranscription) { currentOnTranscription(`__SEGMENT_SAVED__:${reason}`, true); } console.log("[AudioRecordingStore] 收到分段保存通知:", reason); return; } } } catch (error) { console.error("Failed to parse transcription data:", error); } }; ws.onerror = (error) => { const errorMessage = error instanceof Error ? error.message : "WebSocket连接错误,请检查后端服务是否运行"; console.error("WebSocket error:", errorMessage, error); set({ isRecording: false }); if (currentOnError) { currentOnError(new Error(errorMessage)); } }; ws.onclose = (event) => { set({ isRecording: false, recordingStartedAt: null, recordingStartedDate: null, lastFinalEndMs: null, }); // 正常关闭(用户主动停止或服务器正常关闭)不需要触发错误 if (event.wasClean) { shouldReconnectRef = false; currentIs24x7 = false; return; } // 如果已经被标记为不应该重连(用户主动关闭),直接返回 if (!shouldReconnectRef) { console.log("[AudioRecordingStore] 已禁用自动重连,跳过重连"); return; } // 异常关闭:如果是 7×24 模式,尝试自动重连 if (currentIs24x7 && shouldReconnectRef && reconnectAttemptsRef < maxReconnectAttempts) { reconnectAttemptsRef++; console.log( `[AudioRecordingStore] WebSocket 连接断开,${reconnectDelayMs / 1000}秒后尝试重连 (${reconnectAttemptsRef}/${maxReconnectAttempts})` ); // 清理资源但保留回调(用于重连) cleanupRecordingResources(undefined, true); reconnectTimeoutRef = setTimeout(() => { if (currentOnTranscription && shouldReconnectRef) { console.log("[AudioRecordingStore] 尝试重新连接 WebSocket..."); // 使用保存的回调重新启动录音 get().startRecording( currentOnTranscription, currentOnRealtimeNlp || undefined, currentOnError || undefined, currentIs24x7 ).catch((error) => { console.error("[AudioRecordingStore] 重连失败:", error); if (currentOnError) { currentOnError(error as Error); } }); } }, reconnectDelayMs); return; } // 异常关闭提供详细错误信息 let errorMessage = "WebSocket连接异常关闭"; switch (event.code) { case 1006: errorMessage = "WebSocket连接异常断开,可能是网络问题或服务器未响应。请检查:\n1. 后端服务是否正常运行\n2. 网络连接是否正常\n3. 防火墙或代理设置是否正确"; break; case 1000: return; case 1001: errorMessage = "服务器主动断开连接(端点离开)"; break; case 1002: errorMessage = "协议错误导致连接关闭"; break; case 1003: errorMessage = "不支持的数据类型导致连接关闭"; break; case 1007: errorMessage = "数据格式错误导致连接关闭"; break; case 1008: errorMessage = "策略违规导致连接关闭"; break; case 1009: errorMessage = "消息过大导致连接关闭"; break; case 1010: errorMessage = "扩展协商失败导致连接关闭"; break; case 1011: errorMessage = "服务器内部错误导致连接关闭"; break; case 1012: errorMessage = "服务重启导致连接关闭"; break; case 1013: errorMessage = "服务过载导致连接关闭"; break; default: errorMessage = `WebSocket连接异常关闭: ${event.reason || `错误代码 ${event.code}`}`; } console.error("[AudioRecordingStore] WebSocket closed abnormally:", { code: event.code, reason: event.reason, wasClean: event.wasClean, }); if (currentOnError) { currentOnError(new Error(errorMessage)); } }; wsRef = ws; } catch (error) { console.error("Failed to start recording:", error); if (onError) { onError(error as Error); } } }, stopRecording: (segmentTimestamps) => { // 停止自动重连 shouldReconnectRef = false; if (reconnectTimeoutRef) { clearTimeout(reconnectTimeoutRef); reconnectTimeoutRef = null; } reconnectAttemptsRef = 0; currentIs24x7 = false; // 清理录音资源 cleanupRecordingResources(segmentTimestamps); set({ isRecording: false, recordingStartedAt: null, recordingStartedDate: null, lastFinalEndMs: null, }); }, resetLastFinalEnd: () => { set({ lastFinalEndMs: null }); }, updateLastFinalEnd: (ms) => { set({ lastFinalEndMs: ms }); }, // ===== 转录数据更新方法 ===== appendTranscriptionText: (text) => { set((state) => { const prev = state.transcriptionText; const needsGap = prev && !prev.endsWith("\n"); return { transcriptionText: `${prev}${needsGap ? "\n" : ""}${text}\n`, }; }); }, setPartialText: (text) => { set({ partialText: text }); }, setOptimizedText: (text) => { set({ optimizedText: text }); }, appendSegmentData: (data) => { set((state) => ({ segmentTimesSec: [...state.segmentTimesSec, data.timeSec], segmentTimeLabels: [...state.segmentTimeLabels, data.timeLabel], segmentRecordingIds: [...state.segmentRecordingIds, data.recordingId], segmentOffsetsSec: [...state.segmentOffsetsSec, data.offsetSec], })); }, setLiveTodos: (todos) => { set({ liveTodos: todos }); }, setLiveSchedules: (schedules) => { set({ liveSchedules: schedules }); }, clearSessionData: () => { set({ transcriptionText: "", partialText: "", optimizedText: "", segmentTimesSec: [], segmentTimeLabels: [], segmentRecordingIds: [], segmentOffsetsSec: [], liveTodos: [], liveSchedules: [], }); }, })); // ========== 辅助 Hooks ========== /** * 获取录音开始后的经过时间(毫秒) */ export function getRecordingElapsedMs(): number { const { recordingStartedAt } = useAudioRecordingStore.getState(); if (!recordingStartedAt) return 0; return Date.now() - recordingStartedAt; } /** * 获取段落的开始时间(相对于录音开始) * 优先使用 lastFinalEndMs,否则使用录音开始时间 */ export function getSegmentStartMs(): number { const { recordingStartedAt, lastFinalEndMs } = useAudioRecordingStore.getState(); if (!recordingStartedAt) return 0; return lastFinalEndMs ?? recordingStartedAt; } ================================================ FILE: free-todo-frontend/lib/store/breakdown-store.ts ================================================ import { create } from "zustand"; import type { ParsedTodoTree } from "@/apps/chat/types"; import { unwrapApiData } from "@/lib/api/fetcher"; import { createTodoApiTodosPost, updateTodoApiTodosTodoIdPut, } from "@/lib/generated/todos/todos"; import { getQueryClient, queryKeys } from "@/lib/query"; export interface Question { id: string; question: string; options: string[]; type?: "single" | "multiple"; // 可选,默认多选 } type BreakdownStage = "idle" | "questionnaire" | "summary" | "completed"; interface BreakdownStoreState { activeBreakdownTodoId: number | null; stage: BreakdownStage; questions: Question[]; answers: Record; summary: string | null; subtasks: ParsedTodoTree[] | null; isLoading: boolean; isGeneratingSummary: boolean; // 正在生成总结(流式) summaryStreamingText: string | null; // 流式生成的文本(用于显示) isGeneratingQuestions: boolean; // 正在生成问题(流式) questionStreamingCount: number; // 当前已生成的问题数量 questionStreamingTitle: string | null; // 当前正在生成的问题标题 error: string | null; startBreakdown: (todoId: number) => void; setQuestions: (questions: Question[]) => void; setAnswer: (questionId: string, options: string[]) => void; setSummary: (summary: string, subtasks: ParsedTodoTree[]) => void; setSummaryStreaming: (text: string | null) => void; // 设置流式文本 setIsGeneratingSummary: (isGenerating: boolean) => void; // 设置生成状态 setQuestionStreaming: (count: number, title: string | null) => void; // 设置问题流式状态 setIsGeneratingQuestions: (isGenerating: boolean) => void; // 设置问题生成状态 resetBreakdown: () => void; applyBreakdown: () => Promise; } export const useBreakdownStore = create()((set, get) => ({ activeBreakdownTodoId: null, stage: "idle", questions: [], answers: {}, summary: null, subtasks: null, isLoading: false, isGeneratingSummary: false, summaryStreamingText: null, isGeneratingQuestions: false, questionStreamingCount: 0, questionStreamingTitle: null, error: null, startBreakdown: (todoId: number) => { set({ activeBreakdownTodoId: todoId, stage: "questionnaire", questions: [], answers: {}, summary: null, subtasks: null, isLoading: true, error: null, }); }, setQuestions: (questions: Question[]) => { set({ questions, isLoading: false, error: null, }); }, setAnswer: (questionId: string, options: string[]) => { set((state) => ({ answers: { ...state.answers, [questionId]: options, }, })); }, setSummary: (summary: string, subtasks: ParsedTodoTree[]) => { set({ summary, subtasks, stage: "summary", isLoading: false, isGeneratingSummary: false, summaryStreamingText: null, error: null, }); }, setSummaryStreaming: (text: string | null) => { set({ summaryStreamingText: text }); }, setIsGeneratingSummary: (isGenerating: boolean) => { set({ isGeneratingSummary: isGenerating }); }, setQuestionStreaming: (count: number, title: string | null) => { set({ questionStreamingCount: count, questionStreamingTitle: title }); }, setIsGeneratingQuestions: (isGenerating: boolean) => { set({ isGeneratingQuestions: isGenerating, ...(isGenerating ? {} : { questionStreamingCount: 0, questionStreamingTitle: null }), }); }, resetBreakdown: () => { set({ activeBreakdownTodoId: null, stage: "idle", questions: [], answers: {}, summary: null, subtasks: null, isLoading: false, isGeneratingSummary: false, summaryStreamingText: null, isGeneratingQuestions: false, questionStreamingCount: 0, questionStreamingTitle: null, error: null, }); }, applyBreakdown: async () => { const state = get(); if (!state.activeBreakdownTodoId || !state.summary || !state.subtasks) { return; } set({ isLoading: true, error: null }); try { // 更新任务描述 await updateTodoApiTodosTodoIdPut(state.activeBreakdownTodoId, { description: state.summary, }); // 添加子任务 - 递归创建,处理层级关系 const createSubtasks = async ( trees: ParsedTodoTree[], parentId: number | null, ): Promise => { for (const node of trees) { // 创建当前子任务 const apiTodo = await createTodoApiTodosPost({ name: node.name, description: node.description, order: node.order, parent_todo_id: parentId, }); const created = unwrapApiData<{ id: number }>(apiTodo); const createdId = created?.id; // 如果有嵌套子任务,递归创建 if (createdId && node.subtasks && node.subtasks.length > 0) { await createSubtasks(node.subtasks, createdId); } } }; await createSubtasks(state.subtasks, state.activeBreakdownTodoId); // 使 todos 缓存失效,触发重新获取 const queryClient = getQueryClient(); await queryClient.invalidateQueries({ queryKey: queryKeys.todos.all }); // 标记为完成 set({ stage: "completed", isLoading: false, }); // 延迟重置,让用户看到完成状态 setTimeout(() => { get().resetBreakdown(); }, 2000); } catch (error) { console.error("Failed to apply breakdown:", error); set({ error: error instanceof Error ? error.message : "应用拆分失败,请重试", isLoading: false, }); } }, })); ================================================ FILE: free-todo-frontend/lib/store/chat-store.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; interface ChatStoreState { conversationId: string | null; historyOpen: boolean; pendingPrompt: string | null; // 待发送的预设消息(由其他组件触发) pendingNewChat: boolean; // 是否需要先开启新会话再发送消息 setConversationId: (id: string | null) => void; setHistoryOpen: (open: boolean) => void; setPendingPrompt: (prompt: string | null, startNewChat?: boolean) => void; } export const useChatStore = create()( persist( (set) => ({ conversationId: null, historyOpen: false, pendingPrompt: null, pendingNewChat: false, setConversationId: (id) => set({ conversationId: id }), setHistoryOpen: (open) => set({ historyOpen: open }), setPendingPrompt: (prompt, startNewChat = false) => set({ pendingPrompt: prompt, pendingNewChat: startNewChat }), }), { name: "chat-config", storage: createJSONStorage(() => { return { getItem: (name: string): string | null => { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(name); const parsed = stored ? JSON.parse(stored) : null; const state = parsed?.state || parsed || {}; // 验证 conversationId - 刷新后清空,不默认选中历史记录 const conversationId: string | null = null; // 验证 historyOpen const historyOpen: boolean = typeof state.historyOpen === "boolean" ? state.historyOpen : false; return JSON.stringify({ state: { conversationId, historyOpen, }, }); } catch (e) { console.error("Error loading chat config:", e); return null; } }, setItem: (name: string, value: string): void => { if (typeof window === "undefined") return; try { localStorage.setItem(name, value); } catch (e) { console.error("Error saving chat config:", e); } }, removeItem: (name: string): void => { if (typeof window === "undefined") return; localStorage.removeItem(name); }, }; }), }, ), ); ================================================ FILE: free-todo-frontend/lib/store/color-theme.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; export type ColorTheme = | "catppuccin" | "blue" | "neutral"; interface ColorThemeState { colorTheme: ColorTheme; setColorTheme: (colorTheme: ColorTheme) => void; } const isValidColorTheme = (value: string | null): value is ColorTheme => { return ( value === "catppuccin" || value === "blue" || value === "neutral" ); }; const normalizeColorTheme = (value: string | null): ColorTheme => { if (value === "amber-coast") return "catppuccin"; if (isValidColorTheme(value)) return value; return "catppuccin"; }; const colorThemeStorage = { getItem: () => { if (typeof window === "undefined") return null; const saved = localStorage.getItem("color-theme"); const colorTheme = normalizeColorTheme(saved); return JSON.stringify({ state: { colorTheme } }); }, setItem: (_name: string, value: string) => { if (typeof window === "undefined") return; try { const data = JSON.parse(value); const rawTheme = data.state?.colorTheme ?? data.colorTheme ?? "catppuccin"; const colorTheme = normalizeColorTheme(rawTheme); localStorage.setItem("color-theme", colorTheme); } catch (e) { console.error("Error saving color theme:", e); } }, removeItem: () => { if (typeof window === "undefined") return; localStorage.removeItem("color-theme"); }, }; export const useColorThemeStore = create()( persist( (set) => ({ colorTheme: "catppuccin", setColorTheme: (colorTheme) => set({ colorTheme }), }), { name: "color-theme", storage: createJSONStorage(() => colorThemeStorage), }, ), ); ================================================ FILE: free-todo-frontend/lib/store/journal-store.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; export type JournalRefreshMode = "fixed" | "workHours" | "custom"; interface JournalSettingsState { refreshMode: JournalRefreshMode; fixedTime: string; workHoursStart: string; workHoursEnd: string; customTime: string; autoLinkEnabled: boolean; autoGenerateObjectiveEnabled: boolean; autoGenerateAiEnabled: boolean; setRefreshMode: (mode: JournalRefreshMode) => void; setFixedTime: (value: string) => void; setWorkHoursStart: (value: string) => void; setWorkHoursEnd: (value: string) => void; setCustomTime: (value: string) => void; setAutoLinkEnabled: (value: boolean) => void; setAutoGenerateObjectiveEnabled: (value: boolean) => void; setAutoGenerateAiEnabled: (value: boolean) => void; } const journalStorage = { getItem: () => { if (typeof window === "undefined") return null; return localStorage.getItem("journal-settings"); }, setItem: (_name: string, value: string) => { if (typeof window === "undefined") return; localStorage.setItem("journal-settings", value); }, removeItem: () => { if (typeof window === "undefined") return; localStorage.removeItem("journal-settings"); }, }; export const useJournalStore = create()( persist( (set) => ({ refreshMode: "fixed", fixedTime: "04:00", workHoursStart: "10:00", workHoursEnd: "02:00", customTime: "04:00", autoLinkEnabled: true, autoGenerateObjectiveEnabled: false, autoGenerateAiEnabled: false, setRefreshMode: (mode) => set({ refreshMode: mode }), setFixedTime: (value) => set({ fixedTime: value }), setWorkHoursStart: (value) => set({ workHoursStart: value }), setWorkHoursEnd: (value) => set({ workHoursEnd: value }), setCustomTime: (value) => set({ customTime: value }), setAutoLinkEnabled: (value) => set({ autoLinkEnabled: value }), setAutoGenerateObjectiveEnabled: (value) => set({ autoGenerateObjectiveEnabled: value }), setAutoGenerateAiEnabled: (value) => set({ autoGenerateAiEnabled: value }), }), { name: "journal-settings", storage: createJSONStorage(() => journalStorage), }, ), ); ================================================ FILE: free-todo-frontend/lib/store/locale.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; // Supported locales - add new languages here // Future languages: "ja" | "ko" | "ru" | "fr" export type Locale = "zh" | "en"; // Supported locales list for validation and detection const SUPPORTED_LOCALES: Locale[] = ["zh", "en"]; // Default locale when no match is found const DEFAULT_LOCALE: Locale = "en"; interface LocaleState { locale: Locale; setLocale: (locale: Locale) => void; /** Whether the store has been hydrated from localStorage */ _hasHydrated: boolean; /** Set hydration state */ _setHasHydrated: (state: boolean) => void; } const isValidLocale = (value: string | null): value is Locale => { return value !== null && SUPPORTED_LOCALES.includes(value as Locale); }; // Detect system language and return default locale // Returns matching locale if system language is supported, otherwise default const getSystemLocale = (): Locale => { if (typeof navigator === "undefined") return DEFAULT_LOCALE; const browserLang = (navigator.language || navigator.languages?.[0] || "").toLowerCase(); // Match against supported locales by prefix for (const locale of SUPPORTED_LOCALES) { if (browserLang.startsWith(locale)) { return locale; } } return DEFAULT_LOCALE; }; // 同步 locale 到 cookie,使服务端可以读取 // 注意:必须使用同步的 document.cookie,不能使用异步的 Cookie Store API // 否则在 router.refresh() 或页面刷新时,cookie 可能还未设置完成 const syncLocaleToCookie = (locale: Locale) => { if (typeof document === "undefined") return; // 设置 cookie,有效期 1 年 // biome-ignore lint/suspicious/noDocumentCookie: 需要同步设置 cookie 以确保刷新前完成 document.cookie = `locale=${locale};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`; }; const localeStorage = { getItem: () => { if (typeof window === "undefined") return null; const language = localStorage.getItem("language"); // If user has a saved preference, use it; otherwise detect from system language const locale: Locale = isValidLocale(language) ? language : getSystemLocale(); // Sync to cookie on initialization syncLocaleToCookie(locale); return JSON.stringify({ state: { locale } }); }, setItem: (_name: string, value: string) => { if (typeof window === "undefined") return; try { const data = JSON.parse(value); const rawLocale = data.state?.locale || data.locale || getSystemLocale(); const locale: Locale = isValidLocale(rawLocale) ? rawLocale : getSystemLocale(); localStorage.setItem("language", locale); // Sync to cookie syncLocaleToCookie(locale); } catch (e) { console.error("Error saving locale:", e); } }, removeItem: () => { if (typeof window === "undefined") return; localStorage.removeItem("language"); }, }; export const useLocaleStore = create()( persist( (set) => ({ locale: getSystemLocale(), _hasHydrated: false, _setHasHydrated: (state: boolean) => set({ _hasHydrated: state }), setLocale: (locale) => { // Immediately sync to cookie syncLocaleToCookie(locale); set({ locale }); }, }), { name: "locale", storage: createJSONStorage(() => localeStorage), onRehydrateStorage: () => (state) => { // Called when hydration is complete state?._setHasHydrated(true); }, }, ), ); ================================================ FILE: free-todo-frontend/lib/store/notification-store.ts ================================================ import { create } from "zustand"; import { isTauri, isWeb } from "@/lib/utils/platform"; export interface Notification { id: string; title: string; content: string; timestamp: string; source?: string; // 来源端点标识 todoId?: number; // draft todo 的 ID(如果通知来自 draft todo) } export interface PollingEndpoint { id: string; url: string; interval: number; // 毫秒 enabled: boolean; } interface NotificationStoreState { // 当前通知列表 notifications: Notification[]; // 轮询端点配置 endpoints: Map; // 展开/收起状态 isExpanded: boolean; // 已触发系统通知的 ID(用于去重) notifiedIds: Set; // 方法 setNotificationsFromSource: (source: string, notifications: Notification[]) => void; upsertNotification: (notification: Notification) => void; removeNotification: (id: string) => void; removeNotificationsBySource: (source: string) => void; registerEndpoint: (endpoint: PollingEndpoint) => void; unregisterEndpoint: (id: string) => void; toggleExpanded: () => void; setExpanded: (expanded: boolean) => void; getEndpoint: (id: string) => PollingEndpoint | undefined; getAllEndpoints: () => PollingEndpoint[]; } function sortNotifications(notifications: Notification[]): Notification[] { return [...notifications].sort((a, b) => { const aTime = new Date(a.timestamp).getTime(); const bTime = new Date(b.timestamp).getTime(); const safeATime = Number.isNaN(aTime) ? 0 : aTime; const safeBTime = Number.isNaN(bTime) ? 0 : bTime; return safeBTime - safeATime; }); } type TauriNotificationApi = { isPermissionGranted?: () => Promise | boolean; requestPermission?: () => Promise | NotificationPermission; sendNotification?: (options: { title: string; body?: string }) => Promise | void; }; const getTauriNotificationApi = (): TauriNotificationApi | null => { if (typeof window === "undefined") return null; const tauri = (window as Window & { __TAURI__?: { notification?: TauriNotificationApi } }) .__TAURI__; return tauri?.notification ?? null; }; let webPermissionRequest: Promise | null = null; const requestWebPermission = async (): Promise => { if (webPermissionRequest) return webPermissionRequest; webPermissionRequest = Notification.requestPermission(); return webPermissionRequest; }; const showWebNotification = async (notification: Notification): Promise => { if (typeof window === "undefined" || !("Notification" in window)) return; let permission = Notification.permission; if (permission === "default") { try { permission = await requestWebPermission(); } catch { return; } } if (permission !== "granted") return; const title = notification.title || "通知"; try { new Notification(title, { body: notification.content, tag: notification.id, }); } catch { // 静默处理错误,不影响应用运行 } }; const showTauriNotification = async (notification: Notification): Promise => { const api = getTauriNotificationApi(); if (!api?.sendNotification) return; let granted = true; if (api.isPermissionGranted) { try { granted = await api.isPermissionGranted(); } catch { granted = false; } } if (!granted && api.requestPermission) { try { const permission = await api.requestPermission(); granted = permission === "granted"; } catch { granted = false; } } if (!granted) return; try { await api.sendNotification({ title: notification.title || "通知", body: notification.content, }); } catch { // 静默处理错误,不影响应用运行 } }; function notifySystem(notification: Notification): void { if (typeof window === "undefined") return; if (window.electronAPI?.showNotification) { window.electronAPI .showNotification({ id: notification.id, title: notification.title, content: notification.content, timestamp: notification.timestamp, }) .catch((error) => { // 静默处理错误,不影响应用运行 console.warn("Failed to show system notification:", error); }); return; } if (isTauri()) { void showTauriNotification(notification); return; } if (isWeb()) { void showWebNotification(notification); } } export const useNotificationStore = create((set, get) => ({ notifications: [], endpoints: new Map(), isExpanded: false, notifiedIds: new Set(), setNotificationsFromSource: (source, notifications) => { const current = get().notifications; const filtered = current.filter((item) => item.source !== source); const tagged = notifications.map((notification) => ({ ...notification, source, })); const next = sortNotifications([...filtered, ...tagged]); const nextNotifiedIds = new Set(get().notifiedIds); for (const notification of tagged) { if (!nextNotifiedIds.has(notification.id)) { notifySystem(notification); nextNotifiedIds.add(notification.id); } } set({ notifications: next, notifiedIds: nextNotifiedIds }); }, upsertNotification: (notification) => { const current = get().notifications.filter((item) => item.id !== notification.id); const next = sortNotifications([...current, notification]); const nextNotifiedIds = new Set(get().notifiedIds); if (!nextNotifiedIds.has(notification.id)) { notifySystem(notification); nextNotifiedIds.add(notification.id); } set({ notifications: next, notifiedIds: nextNotifiedIds }); }, removeNotification: (id) => { const current = get().notifications; const next = current.filter((item) => item.id !== id); set({ notifications: next }); }, removeNotificationsBySource: (source) => { const current = get().notifications; const next = current.filter((item) => item.source !== source); set({ notifications: next }); }, registerEndpoint: (endpoint) => { const { endpoints } = get(); const newEndpoints = new Map(endpoints); newEndpoints.set(endpoint.id, endpoint); set({ endpoints: newEndpoints }); }, unregisterEndpoint: (id) => { const { endpoints } = get(); const newEndpoints = new Map(endpoints); newEndpoints.delete(id); set({ endpoints: newEndpoints }); }, toggleExpanded: () => { set((state) => ({ isExpanded: !state.isExpanded })); }, setExpanded: (expanded) => { set({ isExpanded: expanded }); }, getEndpoint: (id) => { return get().endpoints.get(id); }, getAllEndpoints: () => { return Array.from(get().endpoints.values()); }, })); ================================================ FILE: free-todo-frontend/lib/store/onboarding-store.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; /** * Onboarding state interface * Manages the user onboarding tour state */ interface OnboardingState { /** Whether the user has completed the onboarding tour */ hasCompletedTour: boolean; /** Current step index (null if not in tour) */ currentStep: number | null; /** Mark the tour as completed */ completeTour: () => void; /** Reset the tour (for testing or re-onboarding) */ resetTour: () => void; /** Set the current step */ setCurrentStep: (step: number | null) => void; } const STORAGE_KEY = "onboarding"; /** * Custom storage for onboarding state * Persists hasCompletedTour to localStorage */ const onboardingStorage = { getItem: () => { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); return JSON.stringify({ state: { hasCompletedTour: parsed.state?.hasCompletedTour ?? false, currentStep: null, // Don't persist currentStep }, }); } } catch (e) { console.error("Error reading onboarding state:", e); } return JSON.stringify({ state: { hasCompletedTour: false, currentStep: null }, }); }, setItem: (_name: string, value: string) => { if (typeof window === "undefined") return; try { const data = JSON.parse(value); // Only persist hasCompletedTour, not currentStep localStorage.setItem( STORAGE_KEY, JSON.stringify({ state: { hasCompletedTour: data.state?.hasCompletedTour ?? false, }, }), ); } catch (e) { console.error("Error saving onboarding state:", e); } }, removeItem: () => { if (typeof window === "undefined") return; localStorage.removeItem(STORAGE_KEY); }, }; /** * Onboarding store hook * Manages the state of the user onboarding tour */ export const useOnboardingStore = create()( persist( (set) => ({ hasCompletedTour: false, currentStep: null, completeTour: () => { set({ hasCompletedTour: true, currentStep: null }); }, resetTour: () => { set({ hasCompletedTour: false, currentStep: null }); }, setCurrentStep: (step: number | null) => { set({ currentStep: step }); }, }), { name: STORAGE_KEY, storage: createJSONStorage(() => onboardingStorage), }, ), ); ================================================ FILE: free-todo-frontend/lib/store/theme.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; export type Theme = "light" | "dark" | "system"; interface ThemeState { theme: Theme; _hasHydrated: boolean; setTheme: (theme: Theme) => void; setHasHydrated: (state: boolean) => void; } const themeStorage = { getItem: () => { if (typeof window === "undefined") return null; const theme = localStorage.getItem("theme") || "system"; return JSON.stringify({ state: { theme, _hasHydrated: false, }, }); }, setItem: (_name: string, value: string) => { if (typeof window === "undefined") return; try { const data = JSON.parse(value); const state = data.state || data; if (state.theme) { localStorage.setItem("theme", state.theme); } } catch (e) { console.error("Error saving theme:", e); } }, removeItem: () => { if (typeof window === "undefined") return; localStorage.removeItem("theme"); }, }; export const useThemeStore = create()( persist( (set) => ({ theme: "system", _hasHydrated: false, setTheme: (theme) => set({ theme }), setHasHydrated: (state) => set({ _hasHydrated: state }), }), { name: "theme-config", storage: createJSONStorage(() => themeStorage), onRehydrateStorage: () => (state) => { state?.setHasHydrated(true); }, }, ), ); ================================================ FILE: free-todo-frontend/lib/store/todo-store.ts ================================================ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; /** * Todo UI 状态管理 * 仅管理选中状态和折叠状态等 UI 状态 * 数据获取和变更操作已迁移到 TanStack Query (lib/query/todos.ts) */ interface TodoUIState { /** 当前选中的 todo ID(主选中) */ selectedTodoId: number | null; /** 所有选中的 todo IDs(多选) */ selectedTodoIds: number[]; /** 已折叠的 todo IDs */ collapsedTodoIds: Set; /** 范围选择的锚点 todo ID(用于 Shift 键范围选择) */ anchorTodoId: number | null; // UI 操作 setSelectedTodoId: (id: number | null) => void; setSelectedTodoIds: (ids: number[]) => void; toggleTodoSelection: (id: number) => void; clearTodoSelection: () => void; toggleTodoExpanded: (id: number) => void; isTodoExpanded: (id: number) => boolean; setAnchorTodoId: (id: number | null) => void; /** 当 todo 被删除时清理相关的 UI 状态 */ onTodoDeleted: (deletedIds: number[]) => void; } // 验证和修复存储的数据 function validateTodoSelectionState(state: { selectedTodoId: number | null; selectedTodoIds: number[]; collapsedTodoIds: number[] | Set; }): { selectedTodoId: number | null; selectedTodoIds: number[]; collapsedTodoIds: Set; } { // 验证 selectedTodoId let selectedTodoId: number | null = null; if (state.selectedTodoId && typeof state.selectedTodoId === "number") { selectedTodoId = state.selectedTodoId; } // 验证 selectedTodoIds let selectedTodoIds: number[] = []; if (Array.isArray(state.selectedTodoIds)) { selectedTodoIds = state.selectedTodoIds.filter( (id): id is number => typeof id === "number", ); } // 验证 collapsedTodoIds let collapsedTodoIds: Set; if (state.collapsedTodoIds instanceof Set) { collapsedTodoIds = state.collapsedTodoIds; } else if (Array.isArray(state.collapsedTodoIds)) { collapsedTodoIds = new Set( state.collapsedTodoIds.filter( (id): id is number => typeof id === "number", ), ); } else { collapsedTodoIds = new Set(); } // 确保 selectedTodoId 在 selectedTodoIds 中 if (selectedTodoId && !selectedTodoIds.includes(selectedTodoId)) { selectedTodoIds = [selectedTodoId]; } return { selectedTodoId, selectedTodoIds, collapsedTodoIds, }; } export const useTodoStore = create()( persist( (set, get) => ({ selectedTodoId: null, selectedTodoIds: [], collapsedTodoIds: new Set(), anchorTodoId: null, setSelectedTodoId: (id) => set({ selectedTodoId: id, selectedTodoIds: id ? [id] : [], anchorTodoId: id, // 单独选择时更新锚点 }), setSelectedTodoIds: (ids) => set({ selectedTodoIds: ids, selectedTodoId: ids[0] ?? null, }), toggleTodoSelection: (id) => set((state) => { const exists = state.selectedTodoIds.includes(id); const nextIds = exists ? state.selectedTodoIds.filter((item) => item !== id) : [...state.selectedTodoIds, id]; return { selectedTodoIds: nextIds, selectedTodoId: nextIds[0] ?? null, }; }), clearTodoSelection: () => set({ selectedTodoId: null, selectedTodoIds: [], anchorTodoId: null }), setAnchorTodoId: (id) => set({ anchorTodoId: id }), toggleTodoExpanded: (id) => set((state) => { const newCollapsed = new Set(state.collapsedTodoIds); if (newCollapsed.has(id)) { // 如果已折叠,则展开(从 Set 中移除) newCollapsed.delete(id); } else { // 如果已展开,则折叠(添加到 Set 中) newCollapsed.add(id); } return { collapsedTodoIds: newCollapsed }; }), isTodoExpanded: (id) => { // 如果 id 不在 collapsedTodoIds 中,说明是展开的 return !get().collapsedTodoIds.has(id); }, onTodoDeleted: (deletedIds) => { const deletedSet = new Set(deletedIds); set((state) => ({ selectedTodoId: deletedSet.has(state.selectedTodoId ?? -1) ? null : state.selectedTodoId, selectedTodoIds: state.selectedTodoIds.filter( (x) => !deletedSet.has(x), ), anchorTodoId: deletedSet.has(state.anchorTodoId ?? -1) ? null : state.anchorTodoId, })); }, }), { name: "todo-selection-config", storage: createJSONStorage(() => { return { getItem: (name: string): string | null => { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(name); if (!stored) return null; const parsed = JSON.parse(stored); const state = parsed.state || parsed; // 只持久化选中和折叠状态 const validated = validateTodoSelectionState({ selectedTodoId: state.selectedTodoId ?? null, selectedTodoIds: state.selectedTodoIds ?? [], collapsedTodoIds: state.collapsedTodoIds ?? [], }); // 将 Set 转换为数组以便 JSON 序列化 return JSON.stringify({ state: { selectedTodoId: validated.selectedTodoId, selectedTodoIds: validated.selectedTodoIds, collapsedTodoIds: Array.from(validated.collapsedTodoIds), }, }); } catch (e) { console.error("Error loading todo selection config:", e); return null; } }, setItem: (name: string, value: string): void => { if (typeof window === "undefined") return; try { const data = JSON.parse(value); const state = data.state || data; // 只保存选中和折叠状态 const toSave = { state: { selectedTodoId: state.selectedTodoId ?? null, selectedTodoIds: state.selectedTodoIds ?? [], collapsedTodoIds: Array.isArray(state.collapsedTodoIds) ? state.collapsedTodoIds : state.collapsedTodoIds instanceof Set ? Array.from(state.collapsedTodoIds) : [], }, }; localStorage.setItem(name, JSON.stringify(toSave)); } catch (e) { console.error("Error saving todo selection config:", e); } }, removeItem: (name: string): void => { if (typeof window === "undefined") return; localStorage.removeItem(name); }, }; }), // 只持久化选中和折叠状态 partialize: (state) => ({ selectedTodoId: state.selectedTodoId, selectedTodoIds: state.selectedTodoIds, collapsedTodoIds: Array.from(state.collapsedTodoIds), }), // 恢复状态时,将数组转换回 Set merge: (persistedState, currentState) => { const persisted = persistedState as { selectedTodoId?: number | null; selectedTodoIds?: number[]; collapsedTodoIds?: number[]; }; const validated = validateTodoSelectionState({ selectedTodoId: persisted.selectedTodoId ?? null, selectedTodoIds: persisted.selectedTodoIds ?? [], collapsedTodoIds: persisted.collapsedTodoIds ?? [], }); return { ...currentState, selectedTodoId: validated.selectedTodoId, selectedTodoIds: validated.selectedTodoIds, collapsedTodoIds: validated.collapsedTodoIds, }; }, }, ), ); ================================================ FILE: free-todo-frontend/lib/store/ui-store/index.ts ================================================ // 类型导出 // 布局预设导出 export { LAYOUT_PRESETS } from "./layout-presets"; // Store 导出 export { useUiStore } from "./store"; export type { DockDisplayMode, LayoutPreset, UiStoreState } from "./types"; // 工具函数导出 export { clampWidth, DEFAULT_PANEL_STATE, getPositionByFeature, MAX_PANEL_WIDTH, MIN_PANEL_WIDTH, validatePanelFeatureMap, } from "./utils"; ================================================ FILE: free-todo-frontend/lib/store/ui-store/layout-actions.ts ================================================ import type { StoreApi } from "zustand"; import { LAYOUT_PRESETS } from "./layout-presets"; import type { UiStoreState } from "./types"; import { clampWidth } from "./utils"; type SetState = StoreApi["setState"]; type GetState = StoreApi["getState"]; export const createLayoutActions = (set: SetState, get: GetState) => ({ applyLayout: (layoutId: string) => { const customLayouts = get().customLayouts; const layout = LAYOUT_PRESETS.find((preset) => preset.id === layoutId) || customLayouts.find((preset) => preset.id === layoutId); if (!layout) return; set({ panelFeatureMap: { ...layout.panelFeatureMap }, isPanelAOpen: layout.isPanelAOpen, isPanelBOpen: layout.isPanelBOpen, isPanelCOpen: layout.isPanelCOpen, ...(layout.panelAWidth !== undefined && { panelAWidth: layout.panelAWidth, }), ...(layout.panelCWidth !== undefined && { panelCWidth: layout.panelCWidth, }), }); }, saveCustomLayout: (name: string, options?: { overwrite?: boolean }) => { const trimmedName = name.trim(); if (!trimmedName) return false; const state = get(); const nameKey = trimmedName.toLocaleLowerCase(); const existingIndex = state.customLayouts.findIndex( (layout) => layout.name.toLocaleLowerCase() === nameKey, ); const shouldOverwrite = options?.overwrite ?? false; if (existingIndex >= 0 && !shouldOverwrite) return false; const existing = state.customLayouts[existingIndex]; const layoutId = existing?.id ?? `custom:${encodeURIComponent(trimmedName)}`; const newLayout = { id: layoutId, name: trimmedName, panelFeatureMap: { ...state.panelFeatureMap }, isPanelAOpen: state.isPanelAOpen, isPanelBOpen: state.isPanelBOpen, isPanelCOpen: state.isPanelCOpen, panelAWidth: clampWidth(state.panelAWidth), panelCWidth: clampWidth(state.panelCWidth), }; const nextLayouts = [...state.customLayouts]; if (existingIndex >= 0) { nextLayouts[existingIndex] = newLayout; } else { nextLayouts.push(newLayout); } set({ customLayouts: nextLayouts }); return true; }, renameCustomLayout: ( layoutId: string, name: string, options?: { overwrite?: boolean }, ) => { const trimmedName = name.trim(); if (!trimmedName) return false; const state = get(); const nameKey = trimmedName.toLocaleLowerCase(); const shouldOverwrite = options?.overwrite ?? false; const target = state.customLayouts.find((layout) => layout.id === layoutId); if (!target) return false; const nextLayouts: typeof state.customLayouts = []; for (const layout of state.customLayouts) { const layoutNameKey = layout.name.toLocaleLowerCase(); if (layout.id === layoutId) { nextLayouts.push({ ...layout, name: trimmedName }); continue; } if (layoutNameKey === nameKey) { if (!shouldOverwrite) return false; continue; } nextLayouts.push(layout); } set({ customLayouts: nextLayouts }); return true; }, deleteCustomLayout: (layoutId: string) => set((state) => ({ customLayouts: state.customLayouts.filter( (layout) => layout.id !== layoutId, ), })), }); ================================================ FILE: free-todo-frontend/lib/store/ui-store/layout-presets.ts ================================================ import type { LayoutPreset } from "./types"; // 导出完整的预设布局列表 export const LAYOUT_PRESETS: LayoutPreset[] = [ { id: "default", name: "待办列表模式", panelFeatureMap: { panelA: "todos", panelB: "chat", panelC: "todoDetail", }, isPanelAOpen: true, isPanelBOpen: true, isPanelCOpen: true, panelAWidth: 1 / 3, // panelA 占左边 1/4,panelC 占右边 1/4,所以 panelA 占剩余空间的 1/3 (即 0.25/0.75) panelCWidth: 0.25, // panelC 占右边 1/4 }, { id: "calendar", name: "待办日历模式", panelFeatureMap: { panelA: "calendar", panelB: "todoDetail", panelC: "chat", }, isPanelAOpen: true, isPanelBOpen: true, isPanelCOpen: true, panelAWidth: 0.6, // panelA 占左边 1/2 panelCWidth: 0.25, // panelC 占右边 1/4 }, { id: "lifetrace", name: "LifeTrace 模式", panelFeatureMap: { panelA: "activity", panelB: "debugShots", panelC: null, }, isPanelAOpen: true, isPanelBOpen: true, isPanelCOpen: false, panelAWidth: 2 / 3, // 当 panelA 关闭时,这个值不影响布局 panelCWidth: 1 / 4, }, ]; ================================================ FILE: free-todo-frontend/lib/store/ui-store/storage.ts ================================================ import { createJSONStorage } from "zustand/middleware"; import type { PanelFeature, PanelPosition } from "@/lib/config/panel-config"; import { ALL_PANEL_FEATURES } from "@/lib/config/panel-config"; import type { DockDisplayMode, LayoutPreset, UiStoreState } from "./types"; import { clampWidth, DEFAULT_PANEL_STATE, validatePanelFeatureMap } from "./utils"; type PersistedState = Partial & { panelFeatureMap?: Record; panelPinMap?: Record; customLayouts?: LayoutPreset[]; }; const VALID_POSITIONS: PanelPosition[] = ["panelA", "panelB", "panelC"]; const VALID_DOCK_MODES: DockDisplayMode[] = ["fixed", "auto-hide"]; const VALID_EXTERNAL_TOOL_IDS = new Set(DEFAULT_PANEL_STATE.selectedExternalTools); export const createUiStoreStorage = () => createJSONStorage(() => { const customStorage = { getItem: (name: string): string | null => { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(name); if (!stored) return null; const parsed = JSON.parse(stored) as { state?: PersistedState }; const state = (parsed.state ?? parsed) as PersistedState; // 验证并修复 panelFeatureMap if (state.panelFeatureMap) { state.panelFeatureMap = validatePanelFeatureMap(state.panelFeatureMap); } // 校验 panelPinMap const normalizedPinMap: Record = { ...DEFAULT_PANEL_STATE.panelPinMap, }; if (state.panelPinMap && typeof state.panelPinMap === "object") { for (const position of VALID_POSITIONS) { const value = (state.panelPinMap as Record)[ position ]; if (typeof value === "boolean") { normalizedPinMap[position] = value; } } } state.panelPinMap = normalizedPinMap; // 验证宽度值 if ( typeof state.panelAWidth === "number" && !Number.isNaN(state.panelAWidth) ) { state.panelAWidth = clampWidth(state.panelAWidth); } else { state.panelAWidth = DEFAULT_PANEL_STATE.panelAWidth; } if ( typeof state.panelCWidth === "number" && !Number.isNaN(state.panelCWidth) ) { state.panelCWidth = clampWidth(state.panelCWidth); } else { state.panelCWidth = DEFAULT_PANEL_STATE.panelCWidth; } // 验证布尔值 if (typeof state.isPanelAOpen !== "boolean") { state.isPanelAOpen = DEFAULT_PANEL_STATE.isPanelAOpen; } if (typeof state.isPanelBOpen !== "boolean") { state.isPanelBOpen = DEFAULT_PANEL_STATE.isPanelBOpen; } if (typeof state.isPanelCOpen !== "boolean") { state.isPanelCOpen = DEFAULT_PANEL_STATE.isPanelCOpen; } // 校验禁用功能列表 if (Array.isArray(state.disabledFeatures)) { state.disabledFeatures = state.disabledFeatures.filter( (feature: PanelFeature): feature is PanelFeature => ALL_PANEL_FEATURES.includes(feature), ); } else { state.disabledFeatures = DEFAULT_PANEL_STATE.disabledFeatures; } // 校验后端禁用功能列表 if (Array.isArray(state.backendDisabledFeatures)) { state.backendDisabledFeatures = state.backendDisabledFeatures.filter( (feature: PanelFeature): feature is PanelFeature => ALL_PANEL_FEATURES.includes(feature), ); } else { state.backendDisabledFeatures = DEFAULT_PANEL_STATE.backendDisabledFeatures; } // 后端能力禁用列表不持久化,启动后依赖同步结果 state.backendDisabledFeatures = DEFAULT_PANEL_STATE.backendDisabledFeatures; // 校验自动关闭的panel栈 if (Array.isArray(state.autoClosedPanels)) { state.autoClosedPanels = state.autoClosedPanels.filter( (pos: unknown): pos is PanelPosition => typeof pos === "string" && VALID_POSITIONS.includes(pos as PanelPosition), ); } else { state.autoClosedPanels = DEFAULT_PANEL_STATE.autoClosedPanels; } // 校验 dock 显示模式 if ( !state.dockDisplayMode || !VALID_DOCK_MODES.includes(state.dockDisplayMode) ) { state.dockDisplayMode = DEFAULT_PANEL_STATE.dockDisplayMode; } // 校验 showAgnoToolSelector(默认 false) if (typeof state.showAgnoToolSelector !== "boolean") { state.showAgnoToolSelector = DEFAULT_PANEL_STATE.showAgnoToolSelector; } // 校验 selectedAgnoTools(默认空数组) if (!Array.isArray(state.selectedAgnoTools)) { state.selectedAgnoTools = DEFAULT_PANEL_STATE.selectedAgnoTools; } else { // 确保数组中的元素都是字符串 state.selectedAgnoTools = state.selectedAgnoTools.filter( (tool: unknown): tool is string => typeof tool === "string", ); } // 校验 selectedExternalTools(默认空数组) if (!Array.isArray(state.selectedExternalTools)) { state.selectedExternalTools = DEFAULT_PANEL_STATE.selectedExternalTools; } else { // 确保数组中的元素都是字符串 state.selectedExternalTools = state.selectedExternalTools.filter( (tool: unknown): tool is string => typeof tool === "string" && VALID_EXTERNAL_TOOL_IDS.has(tool), ); } // 校验 customLayouts(默认空数组) if (Array.isArray(state.customLayouts)) { const seenNames = new Set(); state.customLayouts = state.customLayouts .map((layout: unknown): LayoutPreset | null => { if (!layout || typeof layout !== "object") return null; const raw = layout as { id?: unknown; name?: unknown; panelFeatureMap?: unknown; isPanelAOpen?: unknown; isPanelBOpen?: unknown; isPanelCOpen?: unknown; panelAWidth?: unknown; panelCWidth?: unknown; }; if (typeof raw.name !== "string") return null; const name = raw.name.trim(); if (!name) return null; const nameKey = name.toLocaleLowerCase(); if (seenNames.has(nameKey)) return null; seenNames.add(nameKey); const panelFeatureMap = raw.panelFeatureMap ? validatePanelFeatureMap( raw.panelFeatureMap as Record< PanelPosition, PanelFeature | null >, ) : DEFAULT_PANEL_STATE.panelFeatureMap; const panelAWidth = typeof raw.panelAWidth === "number" ? clampWidth(raw.panelAWidth) : DEFAULT_PANEL_STATE.panelAWidth; const panelCWidth = typeof raw.panelCWidth === "number" ? clampWidth(raw.panelCWidth) : DEFAULT_PANEL_STATE.panelCWidth; return { id: typeof raw.id === "string" && raw.id ? raw.id : `custom:${encodeURIComponent(name)}`, name, panelFeatureMap, isPanelAOpen: typeof raw.isPanelAOpen === "boolean" ? raw.isPanelAOpen : DEFAULT_PANEL_STATE.isPanelAOpen, isPanelBOpen: typeof raw.isPanelBOpen === "boolean" ? raw.isPanelBOpen : DEFAULT_PANEL_STATE.isPanelBOpen, isPanelCOpen: typeof raw.isPanelCOpen === "boolean" ? raw.isPanelCOpen : DEFAULT_PANEL_STATE.isPanelCOpen, panelAWidth, panelCWidth, }; }) .filter( (layout): layout is LayoutPreset => Boolean(layout), ); } else { state.customLayouts = DEFAULT_PANEL_STATE.customLayouts; } // 如果有功能被禁用,确保对应位置不再保留 for (const position of Object.keys( state.panelFeatureMap ?? {}, ) as PanelPosition[]) { const feature = state.panelFeatureMap?.[position]; if ( feature && (state.disabledFeatures?.includes(feature) || state.backendDisabledFeatures?.includes(feature)) ) { if (state.panelFeatureMap) { state.panelFeatureMap[position] = null; } } } return JSON.stringify({ state }); } catch (e) { console.error("Error loading panel config:", e); return null; } }, setItem: (name: string, value: string): void => { if (typeof window === "undefined") return; try { localStorage.setItem(name, value); } catch (e) { console.error("Error saving panel config:", e); } }, removeItem: (name: string): void => { if (typeof window === "undefined") return; localStorage.removeItem(name); }, }; return customStorage; }); ================================================ FILE: free-todo-frontend/lib/store/ui-store/store.ts ================================================ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { PanelFeature, PanelPosition } from "@/lib/config/panel-config"; import { ALL_PANEL_FEATURES } from "@/lib/config/panel-config"; import { createLayoutActions } from "./layout-actions"; import { createUiStoreStorage } from "./storage"; import type { UiStoreState } from "./types"; import { clampWidth, DEFAULT_PANEL_STATE, getPositionByFeature, } from "./utils"; export const useUiStore = create()( persist( (set, get) => ({ // 位置槽位初始状态 isPanelAOpen: DEFAULT_PANEL_STATE.isPanelAOpen, isPanelBOpen: DEFAULT_PANEL_STATE.isPanelBOpen, isPanelCOpen: DEFAULT_PANEL_STATE.isPanelCOpen, panelAWidth: DEFAULT_PANEL_STATE.panelAWidth, panelCWidth: DEFAULT_PANEL_STATE.panelCWidth, // 动态功能分配初始状态:默认分配 panelFeatureMap: DEFAULT_PANEL_STATE.panelFeatureMap, panelPinMap: DEFAULT_PANEL_STATE.panelPinMap, // 默认没有禁用的功能 disabledFeatures: DEFAULT_PANEL_STATE.disabledFeatures, backendDisabledFeatures: DEFAULT_PANEL_STATE.backendDisabledFeatures, // 自动关闭的panel栈 autoClosedPanels: DEFAULT_PANEL_STATE.autoClosedPanels, // Dock 显示模式 dockDisplayMode: DEFAULT_PANEL_STATE.dockDisplayMode, // 是否显示 Agno 工具选择器 showAgnoToolSelector: DEFAULT_PANEL_STATE.showAgnoToolSelector, // Agno 模式下选中的 FreeTodo 工具 selectedAgnoTools: DEFAULT_PANEL_STATE.selectedAgnoTools, // Agno 模式下选中的外部工具 selectedExternalTools: DEFAULT_PANEL_STATE.selectedExternalTools, // 用户自定义布局 customLayouts: DEFAULT_PANEL_STATE.customLayouts, // 位置槽位 toggle 方法 togglePanelA: () => set((state) => { const newIsOpen = !state.isPanelAOpen; // 如果用户手动关闭panel,从自动关闭栈中移除 // 如果用户手动打开panel,清空自动关闭栈(用户意图改变了布局) const newAutoClosedPanels = newIsOpen ? [] : state.autoClosedPanels.filter((pos) => pos !== "panelA"); return { isPanelAOpen: newIsOpen, autoClosedPanels: newAutoClosedPanels, }; }), togglePanelB: () => set((state) => { const newIsOpen = !state.isPanelBOpen; const newAutoClosedPanels = newIsOpen ? [] : state.autoClosedPanels.filter((pos) => pos !== "panelB"); return { isPanelBOpen: newIsOpen, autoClosedPanels: newAutoClosedPanels, }; }), togglePanelC: () => set((state) => { const newIsOpen = !state.isPanelCOpen; const newAutoClosedPanels = newIsOpen ? [] : state.autoClosedPanels.filter((pos) => pos !== "panelC"); return { isPanelCOpen: newIsOpen, autoClosedPanels: newAutoClosedPanels, }; }), // 位置槽位宽度设置方法 setPanelAWidth: (width: number) => set((state) => { if ( !state.isPanelAOpen || (!state.isPanelBOpen && !state.isPanelCOpen) ) { return state; } return { panelAWidth: clampWidth(width), }; }), setPanelCWidth: (width: number) => set((state) => { // 允许在 panelC 打开且至少有一个左侧面板(A 或 B)打开时调整宽度 if ( !state.isPanelCOpen || (!state.isPanelAOpen && !state.isPanelBOpen) ) { return state; } return { panelCWidth: clampWidth(width), }; }), // 动态功能分配方法 setPanelFeature: (position, feature) => set((state) => { // 禁用的功能不允许分配 if ( state.disabledFeatures.includes(feature) || state.backendDisabledFeatures.includes(feature) ) { return state; } // 固定面板不允许替换 const currentMap = { ...state.panelFeatureMap }; const currentFeature = currentMap[position]; if ( state.panelPinMap[position] && currentFeature !== feature ) { return state; } // 如果该功能已经在其他位置,先清除那个位置的分配 for (const [pos, assignedFeature] of Object.entries(currentMap) as [ PanelPosition, PanelFeature | null, ][]) { if (assignedFeature === feature && pos !== position) { if (state.panelPinMap[pos]) { return state; } currentMap[pos] = null; } } // 设置新位置的功能 currentMap[position] = feature; return { panelFeatureMap: currentMap }; }), getFeatureByPosition: (position) => { const state = get(); const feature = state.panelFeatureMap[position]; if (!feature) return null; if (state.disabledFeatures.includes(feature)) return null; if (state.backendDisabledFeatures.includes(feature)) return null; return feature; }, getAvailableFeatures: () => { const state = get(); const disabledSet = new Set([ ...state.disabledFeatures, ...state.backendDisabledFeatures, ]); const assignedFeatures = Object.values(state.panelFeatureMap).filter( (f): f is PanelFeature => f !== null, ); return ALL_PANEL_FEATURES.filter( (feature) => !assignedFeatures.includes(feature) && !disabledSet.has(feature), ); }, setFeatureEnabled: (feature, enabled) => set((state) => { const disabledFeatures = new Set(state.disabledFeatures); const panelFeatureMap = { ...state.panelFeatureMap }; if (!enabled) { disabledFeatures.add(feature); // 移除已分配到任何面板的禁用功能 for (const position of Object.keys( panelFeatureMap, ) as PanelPosition[]) { if (panelFeatureMap[position] === feature) { panelFeatureMap[position] = null; } } } else { if (!state.backendDisabledFeatures.includes(feature)) { disabledFeatures.delete(feature); } } return { disabledFeatures: Array.from(disabledFeatures), panelFeatureMap, }; }), isFeatureEnabled: (feature) => { const state = get(); return ( !state.disabledFeatures.includes(feature) && !state.backendDisabledFeatures.includes(feature) ); }, setPanelPinned: (position, pinned) => set((state) => ({ panelPinMap: { ...state.panelPinMap, [position]: pinned, }, })), togglePanelPinned: (position) => set((state) => ({ panelPinMap: { ...state.panelPinMap, [position]: !state.panelPinMap[position], }, })), // 兼容性方法:基于功能的访问 getIsFeatureOpen: (feature) => { const position = getPositionByFeature(feature, get().panelFeatureMap); const state = get(); if ( !position || state.disabledFeatures.includes(feature) || state.backendDisabledFeatures.includes(feature) ) { return false; } switch (position) { case "panelA": return state.isPanelAOpen; case "panelB": return state.isPanelBOpen; case "panelC": return state.isPanelCOpen; } }, toggleFeature: (feature) => { const position = getPositionByFeature(feature, get().panelFeatureMap); if (!position) return; const state = get(); switch (position) { case "panelA": state.togglePanelA(); break; case "panelB": state.togglePanelB(); break; case "panelC": state.togglePanelC(); break; } }, getFeatureWidth: (feature) => { const position = getPositionByFeature(feature, get().panelFeatureMap); if (!position) return 0; const state = get(); switch (position) { case "panelA": return state.panelAWidth; case "panelB": // panelB 的宽度是计算值:1 - panelAWidth return 1 - state.panelAWidth; case "panelC": return state.panelCWidth; } }, setFeatureWidth: (feature, width) => { const position = getPositionByFeature(feature, get().panelFeatureMap); if (!position) return; const state = get(); switch (position) { case "panelA": state.setPanelAWidth(width); break; case "panelB": // panelB 的宽度通过设置 panelA 的宽度来间接设置 // 如果设置 panelB 的宽度为 w,则 panelA 的宽度应该是 1 - w state.setPanelAWidth(1 - width); break; case "panelC": state.setPanelCWidth(width); break; } }, ...createLayoutActions(set, get), swapPanelPositions: (position1, position2) => { set((state) => { // 如果两个位置相同,不需要交换 if (position1 === position2) return state; if (state.panelPinMap[position1] || state.panelPinMap[position2]) { return state; } const newMap = { ...state.panelFeatureMap }; // 交换两个位置的功能 const feature1 = newMap[position1]; const feature2 = newMap[position2]; newMap[position1] = feature2; newMap[position2] = feature1; // 获取两个位置的当前激活状态 const getIsOpen = (pos: PanelPosition): boolean => { switch (pos) { case "panelA": return state.isPanelAOpen; case "panelB": return state.isPanelBOpen; case "panelC": return state.isPanelCOpen; } }; const isOpen1 = getIsOpen(position1); const isOpen2 = getIsOpen(position2); // 构建更新对象,同时交换功能映射和激活状态 const updates: Partial = { panelFeatureMap: newMap, }; // 交换激活状态:将 position1 的激活状态设置为 position2 的,反之亦然 const setPanelOpen = ( pos: PanelPosition, isOpen: boolean, ) => { switch (pos) { case "panelA": updates.isPanelAOpen = isOpen; break; case "panelB": updates.isPanelBOpen = isOpen; break; case "panelC": updates.isPanelCOpen = isOpen; break; } }; setPanelOpen(position1, isOpen2); setPanelOpen(position2, isOpen1); return updates; }); }, // 自动关闭panel管理方法 setAutoClosePanel: (position) => set((state) => { // 如果panel已经在栈中,不重复添加 if (state.autoClosedPanels.includes(position)) { return state; } // 关闭panel并推入栈 const newAutoClosedPanels = [...state.autoClosedPanels, position]; const updates: Partial = { autoClosedPanels: newAutoClosedPanels, }; switch (position) { case "panelA": updates.isPanelAOpen = false; break; case "panelB": updates.isPanelBOpen = false; break; case "panelC": updates.isPanelCOpen = false; break; } return updates; }), restoreAutoClosedPanel: () => set((state) => { // 如果栈为空,不执行任何操作 if (state.autoClosedPanels.length === 0) { return state; } // 从栈顶弹出最近关闭的panel const newAutoClosedPanels = [...state.autoClosedPanels]; const positionToRestore = newAutoClosedPanels.pop(); // 如果pop返回undefined,不执行任何操作 if (!positionToRestore) { return state; } const updates: Partial = { autoClosedPanels: newAutoClosedPanels, }; // 恢复panel switch (positionToRestore) { case "panelA": updates.isPanelAOpen = true; break; case "panelB": updates.isPanelBOpen = true; break; case "panelC": updates.isPanelCOpen = true; break; } return updates; }), clearAutoClosedPanels: () => set(() => ({ autoClosedPanels: [], })), // Dock 显示模式设置方法 setDockDisplayMode: (mode) => set(() => ({ dockDisplayMode: mode, })), // 设置是否显示 Agno 工具选择器 setShowAgnoToolSelector: (show) => set(() => ({ showAgnoToolSelector: show, })), // 设置 Agno 模式下选中的 FreeTodo 工具 setSelectedAgnoTools: (tools) => set(() => ({ selectedAgnoTools: tools, })), // 设置 Agno 模式下选中的外部工具 setSelectedExternalTools: (tools) => set(() => ({ selectedExternalTools: tools, })), setBackendDisabledFeatures: (features) => set((state) => { const sanitized = features.filter((feature) => ALL_PANEL_FEATURES.includes(feature), ); const panelFeatureMap = { ...state.panelFeatureMap }; for (const position of Object.keys( panelFeatureMap, ) as PanelPosition[]) { const feature = panelFeatureMap[position]; if (feature && sanitized.includes(feature)) { panelFeatureMap[position] = null; } } return { backendDisabledFeatures: sanitized, panelFeatureMap, }; }), }), { name: "ui-panel-config", storage: createUiStoreStorage(), }, ), ); ================================================ FILE: free-todo-frontend/lib/store/ui-store/types.ts ================================================ import type { PanelFeature, PanelPosition } from "@/lib/config/panel-config"; // Dock 显示模式类型 export type DockDisplayMode = "fixed" | "auto-hide"; // 布局预设类型 export interface LayoutPreset { id: string; name: string; panelFeatureMap: Record; isPanelAOpen: boolean; isPanelBOpen: boolean; isPanelCOpen: boolean; panelAWidth?: number; panelCWidth?: number; } // UI Store 状态接口 export interface UiStoreState { // 位置槽位状态 isPanelAOpen: boolean; isPanelBOpen: boolean; isPanelCOpen: boolean; // 位置槽位宽度 panelAWidth: number; panelCWidth: number; // panelBWidth 是计算值,不需要单独存储 // 动态功能分配映射:每个位置当前显示的功能 panelFeatureMap: Record; // 面板是否固定(固定面板不会被替换) panelPinMap: Record; // 被禁用的功能列表 disabledFeatures: PanelFeature[]; // 后端能力不足导致的禁用功能列表 backendDisabledFeatures: PanelFeature[]; // 自动关闭的panel栈(记录因窗口缩小而自动关闭的panel,从右到左的顺序) autoClosedPanels: PanelPosition[]; // Dock 显示模式:固定显示或鼠标离开时自动隐藏 dockDisplayMode: DockDisplayMode; // 是否显示 Agno 模式的工具选择器(默认关闭) showAgnoToolSelector: boolean; // Agno 模式下选中的 FreeTodo 工具列表(空数组表示不使用任何工具) selectedAgnoTools: string[]; // Agno 模式下选中的外部工具列表(如 ['duckduckgo']) selectedExternalTools: string[]; // 位置槽位 toggle 方法 togglePanelA: () => void; togglePanelB: () => void; togglePanelC: () => void; // 位置槽位宽度设置方法 setPanelAWidth: (width: number) => void; setPanelCWidth: (width: number) => void; // panelBWidth 是计算值,不需要单独设置方法 // 动态功能分配方法 setPanelFeature: (position: PanelPosition, feature: PanelFeature) => void; getFeatureByPosition: (position: PanelPosition) => PanelFeature | null; getAvailableFeatures: () => PanelFeature[]; setFeatureEnabled: (feature: PanelFeature, enabled: boolean) => void; setBackendDisabledFeatures: (features: PanelFeature[]) => void; isFeatureEnabled: (feature: PanelFeature) => boolean; // 面板固定设置 setPanelPinned: (position: PanelPosition, pinned: boolean) => void; togglePanelPinned: (position: PanelPosition) => void; // 兼容性方法:为了保持向后兼容,保留基于功能的访问方法 // 这些方法内部会通过动态映射查找位置 getIsFeatureOpen: (feature: PanelFeature) => boolean; toggleFeature: (feature: PanelFeature) => void; getFeatureWidth: (feature: PanelFeature) => number; setFeatureWidth: (feature: PanelFeature, width: number) => void; // 应用预设布局 applyLayout: (layoutId: string) => void; // 交换两个面板的位置(功能分配) swapPanelPositions: ( position1: PanelPosition, position2: PanelPosition, ) => void; // 用户自定义布局 customLayouts: LayoutPreset[]; saveCustomLayout: (name: string, options?: { overwrite?: boolean }) => boolean; renameCustomLayout: ( layoutId: string, name: string, options?: { overwrite?: boolean }, ) => boolean; deleteCustomLayout: (layoutId: string) => void; // 自动关闭panel管理方法 setAutoClosePanel: (position: PanelPosition) => void; restoreAutoClosedPanel: () => void; clearAutoClosedPanels: () => void; // Dock 显示模式设置方法 setDockDisplayMode: (mode: DockDisplayMode) => void; // 设置是否显示 Agno 工具选择器 setShowAgnoToolSelector: (show: boolean) => void; // 设置 Agno 模式下选中的 FreeTodo 工具 setSelectedAgnoTools: (tools: string[]) => void; // 设置 Agno 模式下选中的外部工具 setSelectedExternalTools: (tools: string[]) => void; } ================================================ FILE: free-todo-frontend/lib/store/ui-store/utils.ts ================================================ import type { PanelFeature, PanelPosition } from "@/lib/config/panel-config"; import { ALL_PANEL_FEATURES, DEV_IN_PROGRESS_FEATURES, } from "@/lib/config/panel-config"; import type { DockDisplayMode } from "./types"; // 宽度限制常量 export const MIN_PANEL_WIDTH = 0.2; export const MAX_PANEL_WIDTH = 0.8; /** * 限制宽度在有效范围内 */ export function clampWidth(width: number): number { if (Number.isNaN(width)) return 0.5; if (width < MIN_PANEL_WIDTH) return MIN_PANEL_WIDTH; if (width > MAX_PANEL_WIDTH) return MAX_PANEL_WIDTH; return width; } /** * 根据功能查找其所在的位置 */ export function getPositionByFeature( feature: PanelFeature, panelFeatureMap: Record, ): PanelPosition | null { for (const [position, assignedFeature] of Object.entries(panelFeatureMap) as [ PanelPosition, PanelFeature | null, ][]) { if (assignedFeature === feature) { return position; } } return null; } // Panel 配置的默认值 export const DEFAULT_PANEL_STATE = { isPanelAOpen: true, isPanelBOpen: true, isPanelCOpen: true, panelAWidth: 1 / 3, // panelA 占左边 1/4,panelC 占右边 1/4,所以 panelA 占剩余空间的 1/3 (即 0.25/0.75) panelCWidth: 0.25, // panelC 占右边 1/4 // 默认关闭的功能:开发中的面板(用户可在设置中手动开启) disabledFeatures: DEV_IN_PROGRESS_FEATURES as PanelFeature[], backendDisabledFeatures: [] as PanelFeature[], panelFeatureMap: { panelA: "todos" as PanelFeature, panelB: "chat" as PanelFeature, panelC: "todoDetail" as PanelFeature, }, panelPinMap: { panelA: false, panelB: false, panelC: false, }, autoClosedPanels: [] as PanelPosition[], dockDisplayMode: "fixed" as DockDisplayMode, // 是否显示 Agno 模式的工具选择器(默认开启) showAgnoToolSelector: true, // Agno 模式下选中的 FreeTodo 工具列表(默认只选中 todo 管理类工具) selectedAgnoTools: [ "create_todo", "complete_todo", "update_todo", "list_todos", "search_todos", "delete_todo", ] as string[], // Agno 模式下选中的外部工具列表(默认全部选中) selectedExternalTools: [ "websearch", "hackernews", "file", "local_fs", "shell", "sleep", ] as string[], customLayouts: [], }; /** * 验证 panelFeatureMap 的有效性 */ export function validatePanelFeatureMap( map: Record, ): Record { const validated: Record = { panelA: null, panelB: null, panelC: null, }; for (const [position, feature] of Object.entries(map) as [ PanelPosition, PanelFeature | null, ][]) { if (feature && ALL_PANEL_FEATURES.includes(feature)) { validated[position] = feature; } } // 如果验证后所有位置都是 null,使用默认值 if ( validated.panelA === null && validated.panelB === null && validated.panelC === null ) { return DEFAULT_PANEL_STATE.panelFeatureMap; } return validated; } ================================================ FILE: free-todo-frontend/lib/store/ui-store.ts ================================================ // 为保持向后兼容,从拆分后的模块重新导出所有内容 export * from "./ui-store/index"; ================================================ FILE: free-todo-frontend/lib/toast.ts ================================================ /** * 简单的Toast通知工具 * 使用浏览器原生API实现简单的通知功能 */ export type ToastType = "success" | "error" | "info" | "warning"; export interface ToastOptions { duration?: number; type?: ToastType; } let toastContainer: HTMLDivElement | null = null; function getToastContainer(): HTMLDivElement { if (!toastContainer && typeof document !== "undefined") { toastContainer = document.createElement("div"); toastContainer.id = "toast-container"; toastContainer.className = "fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none"; document.body.appendChild(toastContainer); } if (!toastContainer) { throw new Error("Toast container not available"); } return toastContainer; } function createToastElement(message: string, type: ToastType): HTMLDivElement { const toast = document.createElement("div"); toast.className = `pointer-events-auto rounded-lg border px-4 py-3 shadow-lg transition-all animate-in slide-in-from-top-2 ${ type === "success" ? "bg-green-50 border-green-200 text-green-800 dark:bg-green-950 dark:border-green-800 dark:text-green-200" : type === "error" ? "bg-red-50 border-red-200 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200" : type === "warning" ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-200" : "bg-primary/10 border-primary/30 text-primary dark:bg-primary/20 dark:border-primary/40 dark:text-primary" }`; toast.textContent = message; return toast; } export function toast(message: string, options: ToastOptions = {}): void { if (typeof document === "undefined") { console.log(`[Toast ${options.type || "info"}]: ${message}`); return; } const { duration = 3000, type = "info" } = options; const container = getToastContainer(); const toastElement = createToastElement(message, type); container.appendChild(toastElement); setTimeout(() => { toastElement.style.opacity = "0"; toastElement.style.transform = "translateY(-10px)"; setTimeout(() => { if (toastElement.parentNode) { toastElement.parentNode.removeChild(toastElement); } }, 200); }, duration); } export const toastSuccess = ( message: string, options?: Omit, ) => toast(message, { ...options, type: "success" }); export const toastError = ( message: string, options?: Omit, ) => toast(message, { ...options, type: "error" }); export const toastInfo = ( message: string, options?: Omit, ) => toast(message, { ...options, type: "info" }); export const toastWarning = ( message: string, options?: Omit, ) => toast(message, { ...options, type: "warning" }); ================================================ FILE: free-todo-frontend/lib/types/index.ts ================================================ /** * Unified frontend types (camelCase) * These types match the auto-transformed API response structure from customFetcher. * The fetcher automatically converts snake_case (API) -> camelCase (frontend). */ // ============================================================================ // Todo Types // ============================================================================ export type TodoStatus = "active" | "completed" | "canceled" | "draft"; export type TodoPriority = "high" | "medium" | "low" | "none"; export interface TodoAttachment { id: number; fileName: string; filePath: string; fileSize?: number; mimeType?: string; source?: "user" | "ai"; } export interface Todo { id: number; name: string; summary?: string; description?: string; userNotes?: string; parentTodoId?: number | null; itemType?: string; location?: string; categories?: string; classification?: string; deadline?: string; startTime?: string; endTime?: string; dtstart?: string; dtend?: string; due?: string; duration?: string; timeZone?: string; tzid?: string; isAllDay?: boolean; dtstamp?: string; created?: string; lastModified?: string; sequence?: number; rdate?: string; exdate?: string; recurrenceId?: string; relatedToUid?: string; relatedToReltype?: string; icalStatus?: string; reminderOffsets?: number[] | null; rrule?: string | null; status: TodoStatus; priority: TodoPriority; completedAt?: string; percentComplete?: number; order?: number; tags?: string[]; attachments?: TodoAttachment[]; relatedActivities?: number[]; createdAt: string; updatedAt: string; } export interface CreateTodoInput { name: string; summary?: string; description?: string; userNotes?: string; parentTodoId?: number | null; itemType?: string; location?: string; categories?: string; classification?: string; deadline?: string; startTime?: string; endTime?: string; dtstart?: string; dtend?: string; due?: string; duration?: string; timeZone?: string; tzid?: string; isAllDay?: boolean; dtstamp?: string; created?: string; lastModified?: string; sequence?: number; rdate?: string; exdate?: string; recurrenceId?: string; relatedToUid?: string; relatedToReltype?: string; icalStatus?: string; reminderOffsets?: number[] | null; rrule?: string | null; status?: TodoStatus; priority?: TodoPriority; completedAt?: string; percentComplete?: number; order?: number; tags?: string[]; relatedActivities?: number[]; } export interface UpdateTodoInput { name?: string; summary?: string; description?: string; userNotes?: string; status?: TodoStatus; priority?: TodoPriority; itemType?: string; location?: string; categories?: string; classification?: string; deadline?: string | null; startTime?: string | null; endTime?: string | null; dtstart?: string | null; dtend?: string | null; due?: string | null; duration?: string | null; timeZone?: string | null; tzid?: string | null; isAllDay?: boolean | null; dtstamp?: string | null; created?: string | null; lastModified?: string | null; sequence?: number | null; rdate?: string | null; exdate?: string | null; recurrenceId?: string | null; relatedToUid?: string | null; relatedToReltype?: string | null; icalStatus?: string | null; reminderOffsets?: number[] | null; rrule?: string | null; completedAt?: string | null; percentComplete?: number | null; order?: number; tags?: string[]; parentTodoId?: number | null; relatedActivities?: number[]; } // ============================================================================ // Screenshot & Event Types // ============================================================================ export interface Screenshot { id: number; filePath: string; appName: string; windowTitle: string; createdAt: string; textContent?: string; width: number; height: number; ocrResult?: { textContent: string; }; } export interface Event { id: number; appName: string; windowTitle: string; startTime: string; endTime?: string; screenshotCount: number; firstScreenshotId?: number; screenshots?: Screenshot[]; aiTitle?: string; aiSummary?: string; } // ============================================================================ // Activity Types // ============================================================================ export interface Activity { id: number; startTime: string; endTime: string; aiTitle?: string; aiSummary?: string; eventCount: number; createdAt?: string; updatedAt?: string; } export interface ActivityWithEvents extends Activity { eventIds?: number[]; events?: Event[]; } // ============================================================================ // Utility Types for API List Responses (auto-transformed) // ============================================================================ export interface TodoListResponse { total: number; todos: Todo[]; } export interface ActivityListResponse { total: number; activities: Activity[]; } export interface EventListResponse { total: number; events: Event[]; } export interface ActivityEventsResponse { eventIds: number[]; } // ============================================================================ // Automation Task Types // ============================================================================ export type AutomationScheduleType = "interval" | "cron" | "once"; export interface AutomationSchedule { type: AutomationScheduleType; intervalSeconds?: number; cron?: string; runAt?: string; timezone?: string; } export interface AutomationAction { type: string; payload: Record; } export interface AutomationTask { id: number; name: string; description?: string; enabled: boolean; schedule: AutomationSchedule; action: AutomationAction; lastRunAt?: string; lastStatus?: string; lastError?: string; lastOutput?: string; createdAt: string; updatedAt: string; } export interface AutomationTaskListResponse { total: number; tasks: AutomationTask[]; } export interface AutomationTaskCreateInput { name: string; description?: string; enabled?: boolean; schedule: AutomationSchedule; action: AutomationAction; } export interface AutomationTaskUpdateInput { name?: string; description?: string; enabled?: boolean; schedule?: AutomationSchedule; action?: AutomationAction; } ================================================ FILE: free-todo-frontend/lib/utils/electron-api.ts ================================================ /** * Electron API 类型定义和工具函数 */ export type ElectronAPI = typeof window & { electronAPI?: { collapseWindow?: () => Promise | void; expandWindow?: () => Promise | void; expandWindowFull?: () => Promise | void; setIgnoreMouseEvents?: ( ignore: boolean, options?: { forward?: boolean }, ) => void; resizeWindow?: (dx: number, dy: number, pos: string) => void; quit?: () => void; setWindowBackgroundColor?: (color: string) => void; captureAndExtractTodos?: ( panelBounds?: { x: number; y: number; width: number; height: number } | null, ) => Promise<{ success: boolean; message: string; extractedTodos: Array<{ title: string; description?: string; time_info?: Record; source_text?: string; confidence: number; }>; createdCount: number; }>; }; require?: (module: string) => { ipcRenderer?: { send: (...args: unknown[]) => void }; }; }; export function getElectronAPI(): ElectronAPI { return window as ElectronAPI; } ================================================ FILE: free-todo-frontend/lib/utils/electron.ts ================================================ /** * Electron 环境检测和工具函数 */ /** * Electron 窗口接口扩展 */ export interface ElectronWindow extends Window { electronAPI?: Window["electronAPI"]; require?: (module: string) => unknown; } /** * 检测是否在 Electron 环境中 */ export function isElectronEnvironment(): boolean { if (typeof window === "undefined") return false; const win = window as ElectronWindow; return !!( win.electronAPI || win.require?.("electron") || navigator.userAgent.includes("Electron") ); } ================================================ FILE: free-todo-frontend/lib/utils/platform.ts ================================================ /** * Platform detection utilities * * Provides functions to detect the current runtime environment * (Tauri, Electron, or Web browser) */ /** * Check if running in Tauri environment */ export const isTauri = (): boolean => { return typeof window !== 'undefined' && '__TAURI__' in window; }; /** * Check if running in Electron environment */ export const isElectron = (): boolean => { return typeof window !== 'undefined' && typeof (window as Window & { process?: { type?: string } }).process !== 'undefined' && (window as Window & { process?: { type?: string } }).process?.type === 'renderer'; }; /** * Check if running in a desktop environment (Tauri or Electron) */ export const isDesktop = (): boolean => { return isTauri() || isElectron(); }; /** * Check if running in a web browser (not Tauri or Electron) */ export const isWeb = (): boolean => { return typeof window !== 'undefined' && !isDesktop(); }; /** * Get current platform type */ export type PlatformType = 'tauri' | 'electron' | 'web'; export const getPlatform = (): PlatformType => { if (isTauri()) return 'tauri'; if (isElectron()) return 'electron'; return 'web'; }; /** * Get operating system */ export type OSType = 'windows' | 'macos' | 'linux' | 'unknown'; export const getOS = (): OSType => { if (typeof window === 'undefined') return 'unknown'; const userAgent = window.navigator.userAgent.toLowerCase(); if (userAgent.includes('win')) return 'windows'; if (userAgent.includes('mac')) return 'macos'; if (userAgent.includes('linux')) return 'linux'; return 'unknown'; }; /** * Check if running on macOS */ export const isMacOS = (): boolean => getOS() === 'macos'; /** * Check if running on Windows */ export const isWindows = (): boolean => getOS() === 'windows'; /** * Check if running on Linux */ export const isLinux = (): boolean => getOS() === 'linux'; ================================================ FILE: free-todo-frontend/lib/utils/time.ts ================================================ /** * 时间工具函数 * 处理 UTC 时间和本地时间之间的转换 */ /** * 将 UTC ISO 字符串转换为本地时间字符串(用于 input[type="datetime-local"]) * @param utcIso UTC 时间 ISO 字符串(如 "2025-12-31T15:13:16.855Z") * @returns 本地时间字符串(如 "2025-12-31T23:13"),格式为 YYYY-MM-DDTHH:mm */ export function utcToLocalInput(utcIso: string): string { if (!utcIso) return ""; const date = new Date(utcIso); if (Number.isNaN(date.getTime())) return ""; // 获取本地时间的各个部分 const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day}T${hours}:${minutes}`; } /** * 将本地时间字符串(来自 input[type="datetime-local"])转换为 UTC ISO 字符串 * @param localInput 本地时间字符串(如 "2025-12-31T23:13") * @returns UTC ISO 字符串(如 "2025-12-31T15:13:00.000Z") */ export function localToUtcIso(localInput: string): string { if (!localInput) return ""; const date = new Date(localInput); if (Number.isNaN(date.getTime())) return ""; return date.toISOString(); } /** * 将 UTC ISO 字符串转换为本地时间显示字符串 * @param utcIso UTC 时间 ISO 字符串 * @param format 格式化选项(可选) * @returns 本地时间显示字符串 */ export function utcToLocalDisplay( utcIso: string, format?: "date" | "datetime" | "time", ): string { if (!utcIso) return ""; const date = new Date(utcIso); if (Number.isNaN(date.getTime())) return ""; switch (format) { case "date": return date.toLocaleDateString(); case "time": return date.toLocaleTimeString(); default: return date.toLocaleString(); } } ================================================ FILE: free-todo-frontend/lib/utils.ts ================================================ import clsx, { type ClassValue } from "clsx"; import dayjs from "dayjs"; import { twMerge } from "tailwind-merge"; import "dayjs/locale/zh-cn"; import type { Todo, TodoPriority, TodoStatus } from "./types"; dayjs.locale("zh-cn"); export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // 格式化日期时间 export function formatDateTime( date: string | Date, format = "YYYY-MM-DD HH:mm:ss", ): string { return dayjs(date).format(format); } // 计算时长(秒) export function calculateDuration(startTime: string, endTime: string): number { const start = dayjs(startTime); const end = dayjs(endTime); const seconds = end.diff(start, "second"); // 不足1秒算1秒,使用进一法 return Math.max(1, seconds); } // 格式化时长 export function formatDuration( seconds: number, timeTranslations?: Record, ): string { // 不足1秒算1秒 if (seconds < 1) { seconds = 1; } // 计算各个时间单位 const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; // 如果没有提供翻译,使用中文默认值(向后兼容) if (!timeTranslations) { const parts = []; if (days > 0) parts.push(`${days} 天`); if (hours > 0) parts.push(`${hours} 小时`); if (minutes > 0) parts.push(`${minutes} 分钟`); if (secs > 0) parts.push(`${secs} 秒`); return parts.length > 0 ? parts.join(" ") : "1 秒"; } // 使用提供的翻译 const parts = []; if (days > 0) parts.push(`${days} ${timeTranslations.days}`); if (hours > 0) parts.push(`${hours} ${timeTranslations.hours}`); if (minutes > 0) parts.push(`${minutes} ${timeTranslations.minutes}`); if (secs > 0) parts.push(`${secs} ${timeTranslations.seconds}`); // 如果所有单位都是0(理论上不会发生,因为最小是1秒),返回"1 秒" return parts.length > 0 ? parts.join(" ") : `1 ${timeTranslations.seconds}`; } // ============================================================================ // Todo 排序工具函数 // ============================================================================ /** * 按 order 字段排序 Todo 列表(用于子任务) * 优先按 order 字段排序,如果 order 相同则按创建时间升序排序 */ export function sortTodosByOrder(todos: T[]): T[] { return [...todos].sort((a, b) => { // 优先按 order 字段排序 const aOrder = a.order ?? 0; const bOrder = b.order ?? 0; if (aOrder !== bOrder) { return aOrder - bOrder; } // order 相同时,按创建时间排序 const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; return aTime - bTime; }); } /** * 按原始顺序排序 Todo 列表(用于根任务) * 保持数组中的原始顺序(支持用户拖拽排序) */ export function sortTodosByOriginalOrder( todos: T[], orderMap: Map, ): T[] { return [...todos].sort( (a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0), ); } // ============================================================================ // Todo 国际化工具函数 // ============================================================================ /** * 翻译函数类型 */ export type TranslationFunction = ( key: string, values?: Record, ) => string; /** * 获取优先级的本地化标签 * @param priority 优先级 * @param t 翻译函数(从 useTranslations("common") 获取) */ export function getPriorityLabel( priority: TodoPriority, t: TranslationFunction, ): string { return t(`priority.${priority}`); } /** * 获取状态的本地化标签 * @param status 状态 * @param t 翻译函数(从 useTranslations("common") 获取) */ export function getStatusLabel( status: TodoStatus, t: TranslationFunction, ): string { return t(`status.${status}`); } ================================================ FILE: free-todo-frontend/next.config.ts ================================================ import { execSync } from "node:child_process"; import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin("./lib/i18n/request.ts"); // 获取版本信息 const packageJson = require("./package.json"); const APP_VERSION = packageJson.version; // 获取 Git Commit Hash(取前 8 位) let GIT_COMMIT = "unknown"; try { GIT_COMMIT = execSync("git rev-parse HEAD").toString().trim().slice(0, 8); } catch { console.warn("无法获取 Git commit hash"); } // 判断是 build 版还是 dev 版 const BUILD_TYPE = process.env.NODE_ENV === "production" ? "build" : "dev"; // 从环境变量读取 API 地址,如果读不到就使用 localhost:8100(Build 模式默认端口) const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8100"; const apiUrl = new URL(API_BASE_URL); const nextConfig: NextConfig = { output: "standalone", reactStrictMode: true, typedRoutes: true, // 注入版本信息到客户端环境变量 env: { NEXT_PUBLIC_APP_VERSION: APP_VERSION, NEXT_PUBLIC_GIT_COMMIT: GIT_COMMIT, NEXT_PUBLIC_BUILD_TYPE: BUILD_TYPE, }, // 增加代理超时时间到 120 秒,避免 LLM 调用超时 experimental: { proxyTimeout: 120000, // 120 秒 }, // 在 Electron 环境中禁用 SSR,避免窗口显示问题 // 注意:这会影响 SEO,但对于 Electron 应用来说不是问题 ...(process.env.ELECTRON === "true" ? { // 可以在这里添加 Electron 特定的配置 } : {}), async rewrites() { return [ { source: "/api/:path*", destination: `${API_BASE_URL}/api/:path*`, }, { source: "/assets/:path*", destination: `${API_BASE_URL}/assets/:path*`, }, ]; }, images: { remotePatterns: [ { protocol: apiUrl.protocol.replace(":", "") as "http" | "https", hostname: apiUrl.hostname, port: apiUrl.port || undefined, pathname: "/api/**", }, ], }, }; export default withNextIntl(nextConfig); ================================================ FILE: free-todo-frontend/orval.config.ts ================================================ import { defineConfig } from "orval"; export default defineConfig({ lifetrace: { input: { // 从后端获取 OpenAPI schema target: "http://localhost:8001/openapi.json", }, output: { // 生成文件的目标目录 target: "./lib/generated/generated.ts", schemas: "./lib/generated/schemas", client: "react-query", mode: "tags-split", // 按 API tag 分割文件 override: { mutator: { path: "./lib/api/fetcher.ts", name: "customFetcher", }, // 生成 Zod schemas zod: { strict: { response: true, // 响应使用严格验证 body: true, // 请求体使用严格验证 }, }, query: { useQuery: true, useMutation: true, }, }, prettier: false, }, }, }); ================================================ FILE: free-todo-frontend/package.json ================================================ { "name": "FreeTodo", "version": "0.1.2", "private": true, "main": "dist-electron/main.js", "scripts": { "dev": "node scripts/dev-with-auto-port.js", "dev:frontend:web": "next dev --port 3001", "dev:frontend:island": "next dev --port 3001", "dev:frontend:default-port": "next dev", "electron": "pnpm build:electron:web:script:frontend-shell && electron .", "start": "next start", "build:frontend:web": "next build", "build:frontend:island": "next build", "build:backend:script": "node -e \"console.log('Backend script runtime: no build step required.')\"", "build:backend:pyinstaller": "cd ../lifetrace && bash scripts/build-backend.sh", "build:backend:pyinstaller:win": "cd ../lifetrace && powershell -ExecutionPolicy Bypass -File scripts/build-backend.ps1", "build:electron:web:script:frontend-shell": "cross-env WINDOW_MODE=web BACKEND_RUNTIME=script node scripts/build-electron.js", "build:electron:island:script:frontend-shell": "cross-env WINDOW_MODE=island BACKEND_RUNTIME=script node scripts/build-electron.js", "build:electron:web:pyinstaller:frontend-shell": "cross-env WINDOW_MODE=web BACKEND_RUNTIME=pyinstaller node scripts/build-electron.js", "build:electron:island:pyinstaller:frontend-shell": "cross-env WINDOW_MODE=island BACKEND_RUNTIME=pyinstaller node scripts/build-electron.js", "build:electron:web:script:full": "pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml", "build:electron:web:script:full:win": "pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --win", "build:electron:web:script:full:mac": "pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --mac", "build:electron:web:script:full:linux": "pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --linux", "build:electron:island:script:full": "pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml", "build:electron:island:script:full:win": "pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --win", "build:electron:island:script:full:mac": "pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --mac", "build:electron:island:script:full:linux": "pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --linux", "build:electron:web:pyinstaller:full": "pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml", "build:electron:web:pyinstaller:full:win": "pnpm build:frontend:web && pnpm build:backend:pyinstaller:win && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --win", "build:electron:web:pyinstaller:full:mac": "pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --mac", "build:electron:web:pyinstaller:full:linux": "pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --linux", "build:electron:island:pyinstaller:full": "pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml", "build:electron:island:pyinstaller:full:win": "pnpm build:frontend:island && pnpm build:backend:pyinstaller:win && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --win", "build:electron:island:pyinstaller:full:mac": "pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --mac", "build:electron:island:pyinstaller:full:linux": "pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --linux", "build:electron:web:script:full:dir": "pnpm build:frontend:web && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:script:frontend-shell && electron-builder --config electron-builder.web.script.yml --dir", "build:electron:island:script:full:dir": "pnpm build:frontend:island && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:script:frontend-shell && electron-builder --config electron-builder.island.script.yml --dir", "build:electron:web:pyinstaller:full:dir": "pnpm build:frontend:web && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:web:pyinstaller:frontend-shell && electron-builder --config electron-builder.web.pyinstaller.yml --dir", "build:electron:island:pyinstaller:full:dir": "pnpm build:frontend:island && pnpm build:backend:pyinstaller && pnpm electron:resolve-symlinks && pnpm electron:copy-missing-deps && pnpm build:electron:island:pyinstaller:frontend-shell && electron-builder --config electron-builder.island.pyinstaller.yml --dir", "lint": "biome lint ./app ./apps ./components ./electron ./lib ./scripts", "format": "biome format --write ./app ./apps ./components ./electron ./lib ./scripts", "check": "biome check ./app ./apps ./components ./electron ./lib ./scripts", "type-check": "tsc --noEmit", "api:generate": "orval", "api:generate:watch": "orval --watch", "electron:resolve-symlinks": "node scripts/resolve-symlinks.js", "electron:copy-missing-deps": "node scripts/copy-missing-deps.js", "electron:dev": "pnpm electron", "electron:dev:web": "cross-env WINDOW_MODE=web pnpm electron", "electron:dev:island": "cross-env WINDOW_MODE=island pnpm electron", "electron:dev:utf8": "pnpm electron", "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build", "tauri:prebuild": "node scripts/tauri-prebuild.js", "tauri:copy-resources": "node scripts/tauri-copy-resources.js", "build:tauri:web:script:full": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json && node scripts/tauri-copy-resources.js", "build:tauri:island:script:full": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json && node scripts/tauri-copy-resources.js", "build:tauri:web:pyinstaller:full": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json && node scripts/tauri-copy-resources.js", "build:tauri:island:pyinstaller:full": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json && node scripts/tauri-copy-resources.js", "build:tauri:web:script:full:win": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant web --runtime script --target x86_64-pc-windows-msvc", "build:tauri:web:script:full:mac": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant web --runtime script --target universal-apple-darwin", "build:tauri:web:script:full:linux": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:web && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.web.script.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant web --runtime script --target x86_64-unknown-linux-gnu", "build:tauri:island:script:full:win": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant island --runtime script --target x86_64-pc-windows-msvc", "build:tauri:island:script:full:mac": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant island --runtime script --target universal-apple-darwin", "build:tauri:island:script:full:linux": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=script pnpm build:frontend:island && cross-env FREETODO_BACKEND_RUNTIME=script tauri build --config src-tauri/tauri.island.script.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant island --runtime script --target x86_64-unknown-linux-gnu", "build:tauri:web:pyinstaller:full:win": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller:win && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant web --runtime pyinstaller --target x86_64-pc-windows-msvc", "build:tauri:web:pyinstaller:full:mac": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant web --runtime pyinstaller --target universal-apple-darwin", "build:tauri:web:pyinstaller:full:linux": "cross-env WINDOW_MODE=web FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:web && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.web.pyinstaller.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant web --runtime pyinstaller --target x86_64-unknown-linux-gnu", "build:tauri:island:pyinstaller:full:win": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller:win && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json --target x86_64-pc-windows-msvc && node scripts/tauri-copy-resources.js --target x86_64-pc-windows-msvc && node scripts/collect-tauri-artifacts.js --variant island --runtime pyinstaller --target x86_64-pc-windows-msvc", "build:tauri:island:pyinstaller:full:mac": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json --target universal-apple-darwin && node scripts/tauri-copy-resources.js --target universal-apple-darwin && node scripts/collect-tauri-artifacts.js --variant island --runtime pyinstaller --target universal-apple-darwin", "build:tauri:island:pyinstaller:full:linux": "cross-env WINDOW_MODE=island FREETODO_BACKEND_RUNTIME=pyinstaller pnpm build:frontend:island && pnpm build:backend:pyinstaller && cross-env FREETODO_BACKEND_RUNTIME=pyinstaller tauri build --config src-tauri/tauri.island.pyinstaller.json --target x86_64-unknown-linux-gnu && node scripts/tauri-copy-resources.js --target x86_64-unknown-linux-gnu && node scripts/collect-tauri-artifacts.js --variant island --runtime pyinstaller --target x86_64-unknown-linux-gnu" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.90.20", "@tauri-apps/api": "2.9.1", "@tiptap/core": "^3.18.0", "@tiptap/extension-placeholder": "^3.18.0", "@tiptap/pm": "^3.18.0", "@tiptap/react": "^3.18.0", "@tiptap/starter-kit": "^3.18.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.19", "driver.js": "^1.4.0", "framer-motion": "^12.29.2", "jimp": "^1.6.0", "lucide-react": "^0.563.0", "markdown-it": "^14.1.0", "next": "16.1.6", "next-intl": "^4.8.1", "next-themes": "^0.4.6", "openai": "^6.17.0", "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "sharp": "^0.34.5", "tailwind-merge": "^3.4.0", "turndown": "^7.2.2", "zod": "^4.3.6", "zustand": "^5.0.11" }, "devDependencies": { "@biomejs/biome": "2.3.13", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", "@tauri-apps/cli": "2.9.1", "@types/dom-speech-recognition": "^0.0.7", "@types/markdown-it": "^14.1.2", "@types/node": "^25.1.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@types/turndown": "^5.0.6", "autoprefixer": "^10.4.24", "baseline-browser-mapping": "^2.9.19", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^40.1.0", "electron-builder": "^26.7.0", "electron-builder-squirrel-windows": "26.7.0", "esbuild": "^0.27.2", "orval": "^8.2.0", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "wait-on": "^9.0.3" } } ================================================ FILE: free-todo-frontend/pnpm-workspace.yaml ================================================ packages: - "." onlyBuiltDependencies: - electron - electron-winstaller - esbuild - sharp ================================================ FILE: free-todo-frontend/postcss.config.mjs ================================================ export default { plugins: { "@tailwindcss/postcss": {}, autoprefixer: {}, }, }; ================================================ FILE: free-todo-frontend/public/app-icons/README.md ================================================ # 应用图标目录 此目录用于存放应用图标文件。系统会自动根据应用名称(去除.exe后缀)来查找对应的图标。 ## 使用方法 1. 将应用图标文件(PNG格式)放置在此目录下 2. 文件命名规则:`应用名称(小写,无.exe后缀).png` - 例如:`msedge.exe` → `msedge.png` - 例如:`QQ.exe` → `qq.png` - 例如:`explorer.exe` → `explorer.png` ## 图标要求 - **格式**:PNG(推荐) - **尺寸**:建议 64x64 或 128x128 像素 - **背景**:透明背景(PNG支持透明) ## 示例 以下是一些常见应用的图标文件名: - `msedge.png` - Microsoft Edge - `chrome.png` - Google Chrome - `firefox.png` - Mozilla Firefox - `qq.png` - QQ - `wechat.png` - 微信 - `explorer.png` - Windows 文件资源管理器 - `code.png` - Visual Studio Code - `pycharm.png` - PyCharm ## 注意事项 - 如果应用图标不存在,系统会显示应用名称的首字母作为占位符 - 图标文件名必须与应用名称(去除.exe后缀后转小写)完全匹配 - 建议使用知名应用的官方图标,注意版权问题 ================================================ FILE: free-todo-frontend/public/free-todo-logos/favicon/site.webmanifest ================================================ {"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"} ================================================ FILE: free-todo-frontend/scripts/build-electron.js ================================================ /** * 构建 Electron 主进程 * 使用 esbuild 将 TypeScript 编译为 JavaScript */ const esbuild = require("esbuild"); const path = require("node:path"); const isWatch = process.argv.includes("--watch"); // 获取窗口模式(默认为 web) const windowMode = process.env.WINDOW_MODE || "web"; // 获取后端运行时(script 或 pyinstaller) const backendRuntime = process.env.BACKEND_RUNTIME || "script"; async function build() { console.log( `Building Electron with WINDOW_MODE=${windowMode}, BACKEND_RUNTIME=${backendRuntime}`, ); const mainOptions = { entryPoints: [path.join(__dirname, "..", "electron", "main.ts")], bundle: true, platform: "node", target: "node18", outfile: path.join(__dirname, "..", "dist-electron", "main.js"), external: ["electron"], sourcemap: true, minify: process.env.NODE_ENV === "production", // 在编译时注入窗口模式常量 define: { "__DEFAULT_WINDOW_MODE__": JSON.stringify(windowMode), "__DEFAULT_BACKEND_RUNTIME__": JSON.stringify(backendRuntime), }, }; const preloadOptions = { entryPoints: [path.join(__dirname, "..", "electron", "preload.ts")], bundle: true, platform: "node", target: "node18", outfile: path.join(__dirname, "..", "dist-electron", "preload.js"), external: ["electron"], sourcemap: true, minify: process.env.NODE_ENV === "production", }; if (isWatch) { const mainCtx = await esbuild.context(mainOptions); const preloadCtx = await esbuild.context(preloadOptions); await Promise.all([mainCtx.watch(), preloadCtx.watch()]); console.log("Watching for changes..."); } else { await Promise.all([ esbuild.build(mainOptions), esbuild.build(preloadOptions), ]); console.log("Electron main process and preload script built successfully!"); } } // 处理信号,确保正常退出 let isShuttingDown = false; const gracefulShutdown = async (signal) => { if (isShuttingDown) { console.log(`Received ${signal} again, forcing exit...`); process.exit(1); return; } isShuttingDown = true; console.log(`\nReceived ${signal} signal, shutting down gracefully...`); // 等待当前构建完成 setTimeout(() => { process.exit(0); }, 1000); }; process.on("SIGINT", () => gracefulShutdown("SIGINT")); process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); build().catch((err) => { console.error("Build failed:", err); process.exit(1); }); ================================================ FILE: free-todo-frontend/scripts/check_code_lines.js ================================================ #!/usr/bin/env node /** * Check effective TypeScript/TSX code lines (excluding blank lines and comments). * Files over the limit are reported and the script exits non-zero. * * Usage: * # Scan the entire directory (standalone) * node check_code_lines.js [--include dirs] [--exclude dirs] [--max lines] * * # Check specified files (pre-commit mode) * node check_code_lines.js [options] file1.ts file2.tsx ... * * Examples: * # Scan the whole frontend directory * node check_code_lines.js --include apps,components,lib --exclude lib/generated --max 500 * * # Check specific files (pre-commit passes staged files) * node check_code_lines.js apps/chat/ChatPanel.tsx apps/todo/TodoList.tsx */ const { existsSync, readdirSync, readFileSync } = require("node:fs"); const { dirname, isAbsolute, join, relative, resolve } = require("node:path"); // Script directory (CommonJS) // In CommonJS, __dirname and __filename are available by default. // Default configuration const DEFAULT_INCLUDE = ["apps", "components", "electron", "lib"]; const DEFAULT_EXCLUDE = ["lib/generated"]; const DEFAULT_MAX_LINES = 500; /** * @typedef {Object} Config * @property {string[]} includeDirs * @property {string[]} excludeDirs * @property {number} maxLines * @property {string[]} files - Explicit file list */ /** * Parse CLI arguments. * @returns {Config} */ function parseArgs() { const args = process.argv.slice(2); let includeDirs = DEFAULT_INCLUDE; let excludeDirs = DEFAULT_EXCLUDE; let maxLines = DEFAULT_MAX_LINES; /** @type {string[]} */ const files = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--include" && args[i + 1]) { includeDirs = args[i + 1] .split(",") .map((d) => d.trim()) .filter(Boolean); i++; } else if (arg === "--exclude" && args[i + 1]) { excludeDirs = args[i + 1] .split(",") .map((d) => d.trim()) .filter(Boolean); i++; } else if (arg === "--max" && args[i + 1]) { maxLines = parseInt(args[i + 1], 10); i++; } else if (!arg.startsWith("--")) { // Positional args are treated as file paths files.push(arg); } } return { includeDirs, excludeDirs, maxLines, files }; } /** * Check whether a line is a comment-only line. * * Rule: after trim(), lines starting with //, /*, *, or * / are comments. * @param {string} line * @returns {boolean} */ function isCommentLine(line) { const trimmed = line.trim(); return ( trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("*/") ); } /** * Count effective code lines (excluding blank lines and comments). * @param {string} filePath * @returns {number} */ function countCodeLines(filePath) { try { const content = readFileSync(filePath, "utf-8"); const lines = content.split("\n"); let codeLines = 0; for (const line of lines) { const trimmed = line.trim(); // Skip blank lines if (!trimmed) { continue; } // Skip comment-only lines if (isCommentLine(line)) { continue; } // Counted line codeLines++; } return codeLines; } catch (error) { console.error(`Warning: failed to read file ${filePath}: ${error}`); return 0; } } /** * Normalize path separators to / (Windows-friendly). * @param {string} p * @returns {string} */ function normalizePath(p) { return p.replace(/\\/g, "/"); } /** * Determine whether a file should be checked. * @param {string} relPath * @param {string[]} includeDirs * @param {string[]} excludeDirs * @returns {boolean} */ function shouldCheckFile(relPath, includeDirs, excludeDirs) { // Normalize to / separators const normalizedPath = normalizePath(relPath); // Check include directories const inInclude = includeDirs.some((inc) => normalizedPath.startsWith(normalizePath(inc)) ); if (!inInclude) { return false; } // Check exclude directories const inExclude = excludeDirs.some((exc) => normalizedPath.startsWith(normalizePath(exc)) ); if (inExclude) { return false; } return true; } /** * Recursively walk a directory for .ts and .tsx files. * @param {string} dir * @returns {Generator} */ function* walkDir(dir) { try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { // Skip node_modules and hidden directories if (entry.name === "node_modules" || entry.name.startsWith(".")) { continue; } yield* walkDir(fullPath); } else if (entry.isFile()) { if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { yield fullPath; } } } } catch { // Ignore inaccessible directories } } /** * Get the list of files to check. * @param {Config} config * @param {string} rootDir * @returns {string[]} */ function getFilesToCheck(config, rootDir) { /** @type {string[]} */ const filesToCheck = []; if (config.files.length > 0) { // Mode 1: Check specified files (pre-commit mode) for (const fileStr of config.files) { // Handle relative and absolute paths const filePath = isAbsolute(fileStr) ? fileStr : resolve(fileStr); // Only check .ts/.tsx files if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx")) { continue; } // Skip missing files if (!existsSync(filePath)) { continue; } // Get path relative to frontend root /** @type {string} */ let relPath; try { relPath = relative(rootDir, filePath); // If rel path starts with "..", it's outside the frontend directory if (relPath.startsWith("..")) { continue; } } catch { continue; } // Check include/exclude filters if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) { filesToCheck.push(filePath); } } } else { // Mode 2: Scan entire directory (standalone mode) for (const filePath of walkDir(rootDir)) { const relPath = relative(rootDir, filePath); if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) { filesToCheck.push(filePath); } } } return filesToCheck; } /** * Main entrypoint. * @returns {number} */ function main() { const config = parseArgs(); // Frontend root (script lives in free-todo-frontend/scripts/) const rootDir = dirname(__dirname); // Collect files to check const filesToCheck = getFilesToCheck(config, rootDir); if (filesToCheck.length === 0) { if (config.files.length > 0) { // No matching files in pre-commit mode return 0; } else { console.log("No TS/TSX files to check."); return 0; } } // Collect violations /** @type {Array<{ path: string; lines: number }>} */ const violations = []; for (const filePath of filesToCheck) { const relPath = relative(rootDir, filePath); const codeLines = countCodeLines(filePath); if (codeLines > config.maxLines) { violations.push({ path: relPath, lines: codeLines }); } } // Output results if (violations.length > 0) { console.log( `[ERROR] The following files exceed ${config.maxLines} code lines:` ); violations.sort((a, b) => a.path.localeCompare(b.path)); for (const { path, lines } of violations) { console.log(` ${path} -> ${lines} lines`); } return 1; } else { const modeDesc = config.files.length > 0 ? `Checked ${filesToCheck.length} files, ` : ""; console.log( `[OK] ${modeDesc}all TS/TSX files are within ${config.maxLines} code lines` ); return 0; } } process.exit(main()); ================================================ FILE: free-todo-frontend/scripts/check_rust_code_lines.js ================================================ #!/usr/bin/env node /** * Check effective Rust code lines (excluding blank lines and comments). * Files over the limit are reported and the script exits non-zero. * * Usage: * # Scan the entire directory (standalone) * node check_rust_code_lines.js [--include dirs] [--exclude dirs] [--max lines] * * # Check specified files (pre-commit mode) * node check_rust_code_lines.js [options] file1.rs file2.rs ... */ const { existsSync, readdirSync, readFileSync } = require("node:fs"); const { dirname, isAbsolute, join, relative, resolve } = require("node:path"); const DEFAULT_INCLUDE = ["src-tauri/src"]; const DEFAULT_EXCLUDE = ["src-tauri/target"]; const DEFAULT_MAX_LINES = 500; function parseArgs() { const args = process.argv.slice(2); let includeDirs = DEFAULT_INCLUDE; let excludeDirs = DEFAULT_EXCLUDE; let maxLines = DEFAULT_MAX_LINES; /** @type {string[]} */ const files = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--include" && args[i + 1]) { includeDirs = args[i + 1] .split(",") .map((d) => d.trim()) .filter(Boolean); i++; } else if (arg === "--exclude" && args[i + 1]) { excludeDirs = args[i + 1] .split(",") .map((d) => d.trim()) .filter(Boolean); i++; } else if (arg === "--max" && args[i + 1]) { maxLines = parseInt(args[i + 1], 10); i++; } else if (!arg.startsWith("--")) { files.push(arg); } } return { includeDirs, excludeDirs, maxLines, files }; } function isCommentLine(line) { const trimmed = line.trim(); return ( trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("*/") ); } function countCodeLines(filePath) { try { const content = readFileSync(filePath, "utf-8"); const lines = content.split("\n"); let codeLines = 0; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) { continue; } if (isCommentLine(line)) { continue; } codeLines++; } return codeLines; } catch (error) { console.error(`Warning: failed to read file ${filePath}: ${error}`); return 0; } } function normalizePath(p) { return p.replace(/\\/g, "/"); } function shouldCheckFile(relPath, includeDirs, excludeDirs) { const normalizedPath = normalizePath(relPath); const inInclude = includeDirs.some((inc) => normalizedPath.startsWith(normalizePath(inc)) ); if (!inInclude) { return false; } const inExclude = excludeDirs.some((exc) => normalizedPath.startsWith(normalizePath(exc)) ); if (inExclude) { return false; } return true; } function* walkDir(dir) { try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name.startsWith(".")) { continue; } yield* walkDir(fullPath); } else if (entry.isFile()) { if (entry.name.endsWith(".rs")) { yield fullPath; } } } } catch { // Ignore inaccessible directories } } function getFilesToCheck(config, rootDir) { /** @type {string[]} */ const filesToCheck = []; if (config.files.length > 0) { for (const fileStr of config.files) { const filePath = isAbsolute(fileStr) ? fileStr : resolve(fileStr); if (!filePath.endsWith(".rs")) { continue; } if (!existsSync(filePath)) { continue; } /** @type {string} */ let relPath; try { relPath = relative(rootDir, filePath); if (relPath.startsWith("..")) { continue; } } catch { continue; } if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) { filesToCheck.push(filePath); } } } else { for (const filePath of walkDir(rootDir)) { const relPath = relative(rootDir, filePath); if (shouldCheckFile(relPath, config.includeDirs, config.excludeDirs)) { filesToCheck.push(filePath); } } } return filesToCheck; } function main() { const config = parseArgs(); const rootDir = dirname(__dirname); const filesToCheck = getFilesToCheck(config, rootDir); if (filesToCheck.length === 0) { if (config.files.length > 0) { return 0; } console.log("No Rust files to check."); return 0; } /** @type {Array<{ path: string; lines: number }>} */ const violations = []; for (const filePath of filesToCheck) { const relPath = relative(rootDir, filePath); const codeLines = countCodeLines(filePath); if (codeLines > config.maxLines) { violations.push({ path: relPath, lines: codeLines }); } } if (violations.length > 0) { console.log( `[ERROR] The following files exceed ${config.maxLines} code lines:` ); violations.sort((a, b) => a.path.localeCompare(b.path)); for (const { path, lines } of violations) { console.log(` ${path} -> ${lines} lines`); } return 1; } const modeDesc = config.files.length > 0 ? `Checked ${filesToCheck.length} files, ` : ""; console.log( `[OK] ${modeDesc}all Rust files are within ${config.maxLines} code lines` ); return 0; } process.exit(main()); ================================================ FILE: free-todo-frontend/scripts/collect-tauri-artifacts.js ================================================ const fs = require("node:fs"); const path = require("node:path"); function parseArgs() { const args = process.argv.slice(2); const result = {}; for (let i = 0; i < args.length; i += 1) { const key = args[i]; const value = args[i + 1]; if (key?.startsWith("--") && value && !value.startsWith("--")) { result[key.slice(2)] = value; i += 0; } } return result; } function ensureDir(dir) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } function copyDir(src, dest) { if (!fs.existsSync(src)) { throw new Error(`Source directory not found: ${src}`); } ensureDir(dest); fs.cpSync(src, dest, { recursive: true, force: true }); } const args = parseArgs(); const variant = args.variant; const runtime = args.runtime; const target = args.target; if (!variant || !runtime || !target) { console.error( "Usage: node scripts/collect-tauri-artifacts.js --variant --runtime --target ", ); process.exit(1); } const rootDir = path.resolve(__dirname, ".."); const sourceDir = path.join(rootDir, "src-tauri", "target", target, "release", "bundle"); const destDir = path.join( rootDir, "dist-artifacts", "tauri", variant, runtime, target, ); try { copyDir(sourceDir, destDir); console.log(`Tauri artifacts copied to: ${destDir}`); } catch (error) { console.error(`Failed to collect Tauri artifacts: ${error.message}`); process.exit(1); } ================================================ FILE: free-todo-frontend/scripts/copy-missing-deps.js ================================================ /** * 复制 Next.js standalone 构建中缺失的依赖 * Next.js standalone 可能不会包含所有运行时需要的依赖 */ const fs = require("node:fs"); const path = require("node:path"); function copyDirectory(src, dest) { if (!fs.existsSync(src)) { console.warn(`Source not found: ${src}`); return false; } fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDirectory(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } return true; } // 需要复制的缺失依赖(Next.js 运行时需要的但 standalone 可能不包含的) // 这些是 Next.js 内部使用的依赖,standalone 构建可能不会自动包含 const missingDeps = [ "styled-jsx", "@swc/helpers", "@next/env", "client-only", "buffer-from", "detect-libc", // 可以根据需要添加更多依赖 ]; const standaloneNodeModules = path.join( __dirname, "..", ".next", "standalone", "node_modules", ); const mainNodeModules = path.join(__dirname, "..", "node_modules"); if (!fs.existsSync(standaloneNodeModules)) { console.warn( `Standalone node_modules not found at: ${standaloneNodeModules}`, ); process.exit(1); } console.log("Copying missing dependencies to standalone build..."); for (const dep of missingDeps) { const srcPath = path.join(mainNodeModules, ".pnpm"); const destPath = path.join(standaloneNodeModules, dep); // 对于 scoped packages (@scope/package),pnpm 使用 + 代替 / const pnpmDepName = dep.replace(/\//g, "+"); // 查找依赖在 .pnpm 中的位置 const pnpmDirs = fs .readdirSync(srcPath) .filter((dir) => dir.startsWith(`${pnpmDepName}@`)); if (pnpmDirs.length > 0) { const pnpmPath = path.join(srcPath, pnpmDirs[0], "node_modules", dep); if (fs.existsSync(pnpmPath)) { if (!fs.existsSync(destPath)) { console.log(`Copying ${dep}...`); copyDirectory(pnpmPath, destPath); console.log(`✓ Copied ${dep}`); } else { console.log(`✓ ${dep} already exists`); } } else { console.warn(`Could not find ${dep} at: ${pnpmPath}`); } } else { console.warn( `Could not find ${dep} (looking for ${pnpmDepName}@) in .pnpm directory`, ); } } console.log("Missing dependencies copy complete!"); ================================================ FILE: free-todo-frontend/scripts/dev-with-auto-port.js ================================================ #!/usr/bin/env node /** * 开发服务器启动脚本(支持动态端口探测) * * 功能: * 1. 自动探测可用的前端端口(默认从 3001 开始,避免与 Build 版冲突) * 2. 自动探测 FreeTodo 后端端口(通过 /health 端点验证是否是 FreeTodo 后端) * 3. 设置正确的环境变量并启动 Next.js 开发服务器 * * 使用方法: * pnpm dev - 自动探测端口启动 * pnpm dev:backend - 同时启动后端和前端(需要后端可执行文件) */ const { execSync, spawn } = require("node:child_process"); const fs = require("node:fs"); const http = require("node:http"); const net = require("node:net"); const path = require("node:path"); // 默认端口配置(开发版使用不同的默认端口,避免与 Build 版冲突) const DEFAULT_FRONTEND_PORT = 3001; const _DEFAULT_BACKEND_PORT = 8001; const MAX_PORT_ATTEMPTS = 100; function normalizePath(value) { const resolved = path.resolve(value); return process.platform === "win32" ? resolved.toLowerCase() : resolved; } function isSymlinkedNodeModules() { const nodeModulesPath = path.join(process.cwd(), "node_modules"); try { if (!fs.existsSync(nodeModulesPath)) { return false; } const stat = fs.lstatSync(nodeModulesPath); if (stat.isSymbolicLink()) { return true; } const realPath = fs.realpathSync(nodeModulesPath); return normalizePath(realPath) !== normalizePath(nodeModulesPath); } catch { return false; } } /** * 获取当前 Git Commit * @returns {string|null} - 完整 Commit Hash,获取失败则返回 null */ function getGitCommit() { const envCommit = process.env.FREETODO_GIT_COMMIT || process.env.GIT_COMMIT; if (envCommit) { return envCommit; } try { return execSync("git rev-parse HEAD", { stdio: ["ignore", "pipe", "ignore"], }) .toString() .trim(); } catch { return null; } } const FRONTEND_GIT_COMMIT = getGitCommit(); /** * 检查端口是否可用(同时检查 IPv4 和 IPv6) * @param {number} port - 要检查的端口 * @returns {Promise} - 端口是否可用 */ function isPortAvailable(port) { return new Promise((resolve) => { const server = net.createServer(); server.once("error", () => resolve(false)); server.once("listening", () => { server.close(); resolve(true); }); // 使用 '::' 检查 IPv6(包含 IPv4),与 Next.js 默认行为一致 // 如果系统不支持 IPv6,会自动回退到 IPv4 server.listen(port, "::"); }); } /** * 查找可用端口 * @param {number} startPort - 起始端口 * @param {number} maxAttempts - 最大尝试次数 * @returns {Promise} - 可用的端口 */ async function findAvailablePort(startPort, maxAttempts = MAX_PORT_ATTEMPTS) { for (let offset = 0; offset < maxAttempts; offset++) { const port = startPort + offset; if (await isPortAvailable(port)) { if (offset > 0) { console.log(`Port ${startPort} is in use, using port ${port}`); } return port; } } throw new Error( `Cannot find available port in range ${startPort}-${startPort + maxAttempts}`, ); } /** * 检查指定端口是否运行着 FreeTodo 后端 * 通过调用 /health 端点并验证 app 标识来确认是 FreeTodo 后端 * @param {number} port - 后端端口 * @returns {Promise} - 是否是 FreeTodo 后端 */ async function isFreeTodoBackend(port) { return new Promise((resolve) => { const req = http.get( { hostname: "127.0.0.1", port, path: "/health", timeout: 2000, }, (res) => { let data = ""; res.on("data", (chunk) => { data += chunk; }); res.on("end", () => { try { const json = JSON.parse(data); // 验证是否是 FreeTodo/LifeTrace 后端 // 只检查固定的应用标识字段 if (json.app !== "lifetrace") { resolve(false); return; } const backendCommit = typeof json.git_commit === "string" ? json.git_commit : null; if (FRONTEND_GIT_COMMIT) { if (!backendCommit || backendCommit === "unknown") { resolve(false); return; } if (backendCommit !== FRONTEND_GIT_COMMIT) { console.log( `Skip backend at ${port}: git commit mismatch (${backendCommit})`, ); resolve(false); return; } } resolve(true); } catch { resolve(false); } }); }, ); req.on("error", () => resolve(false)); req.on("timeout", () => { req.destroy(); resolve(false); }); }); } /** * 查找运行中的 FreeTodo 后端端口 * @returns {Promise} - 运行中的 FreeTodo 后端端口,或 null */ async function findRunningBackendPort() { // 先检查开发版默认端口,然后是 Build 版默认端口 const priorityPorts = [8001, 8000]; for (const port of priorityPorts) { if (await isFreeTodoBackend(port)) { return port; } } // 再检查其他可能的端口(跳过已检查的) for (let port = 8002; port < 8100; port++) { if (await isFreeTodoBackend(port)) { return port; } } return null; } async function main() { console.log("Starting development server...\n"); try { // 1. Find available frontend port // If PORT env var is set, use it (Electron main process may have allocated a port) let frontendPort; if (process.env.PORT) { frontendPort = Number.parseInt(process.env.PORT, 10); console.log(`Using frontend port from env: ${frontendPort}`); } else { frontendPort = await findAvailablePort(DEFAULT_FRONTEND_PORT); console.log(`Frontend port: ${frontendPort}`); } // 2. Find running FreeTodo backend port (verify via /health endpoint) console.log(`Searching for FreeTodo backend...`); if (FRONTEND_GIT_COMMIT) { console.log(`Frontend git commit: ${FRONTEND_GIT_COMMIT}`); } const backendPort = await findRunningBackendPort(); if (backendPort) { console.log(`Detected FreeTodo backend running on port: ${backendPort}`); } else { const hint = "Start backend first - python -m lifetrace.server"; const suffix = FRONTEND_GIT_COMMIT ? ` (git commit: ${FRONTEND_GIT_COMMIT})` : ""; throw new Error( `FreeTodo backend not detected via /health endpoint${suffix}. ${hint}`, ); } const backendUrl = `http://localhost:${backendPort}`; console.log(`\nBackend API: ${backendUrl}`); console.log(`Frontend URL: http://localhost:${frontendPort}\n`); const disableTurbopack = isSymlinkedNodeModules(); if (disableTurbopack && !process.env.NEXT_DISABLE_TURBOPACK) { console.log( "Detected symlinked node_modules, disabling Turbopack for compatibility.", ); } const nextEnv = { ...process.env, PORT: String(frontendPort), NEXT_PUBLIC_API_URL: backendUrl, }; if (disableTurbopack && !("NEXT_DISABLE_TURBOPACK" in nextEnv)) { nextEnv.NEXT_DISABLE_TURBOPACK = "1"; } // 3. 启动 Next.js 开发服务器 const nextArgs = ["next", "dev", "--port", String(frontendPort)]; if (disableTurbopack) { nextArgs.push("--webpack"); } const nextProcess = spawn("pnpm", nextArgs, { stdio: "inherit", env: { ...nextEnv, }, shell: true, }); // 处理进程信号 process.on("SIGINT", () => { nextProcess.kill("SIGINT"); process.exit(0); }); process.on("SIGTERM", () => { nextProcess.kill("SIGTERM"); process.exit(0); }); nextProcess.on("exit", (code) => { process.exit(code || 0); }); } catch (error) { console.error(`Failed to start: ${error.message}`); process.exit(1); } } main(); ================================================ FILE: free-todo-frontend/scripts/electron-dev-electron.ps1 ================================================ # PowerShell script to run Electron only (without starting frontend dev server) # This assumes the frontend dev server is already running separately # Use: pnpm electron:dev:electron (after starting frontend with pnpm electron:dev:frontend) # Set console output encoding to UTF-8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 # Change code page to UTF-8 (65001) chcp 65001 | Out-Null # Build Electron main process and run Electron cd $PSScriptRoot/.. pnpm electron:build-main electron . ================================================ FILE: free-todo-frontend/scripts/electron-dev.ps1 ================================================ # PowerShell script to run electron:dev with UTF-8 encoding # This ensures Next.js output displays correctly without garbled characters # Set console output encoding to UTF-8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8 # Change code page to UTF-8 (65001) chcp 65001 | Out-Null # Build Electron main process and run Electron cd $PSScriptRoot/.. pnpm electron:build-main electron . ================================================ FILE: free-todo-frontend/scripts/resolve-symlinks.js ================================================ /** * 解析 standalone 构建中的 pnpm 符号链接 * 将符号链接替换为实际文件,以便在打包的应用中正常工作 */ const fs = require("node:fs"); const path = require("node:path"); function resolveSymlinks(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isSymbolicLink()) { try { const target = fs.readlinkSync(fullPath); const resolvedPath = path.isAbsolute(target) ? target : path.resolve(path.dirname(fullPath), target); // 检查目标是否存在 if (fs.existsSync(resolvedPath)) { // 删除符号链接 fs.unlinkSync(fullPath); // 如果是目录,复制整个目录 if (fs.statSync(resolvedPath).isDirectory()) { copyDirectory(resolvedPath, fullPath); } else { // 如果是文件,复制文件 fs.copyFileSync(resolvedPath, fullPath); } console.log(`Resolved symlink: ${entry.name} -> ${resolvedPath}`); } else { console.warn(`Symlink target not found: ${fullPath} -> ${target}`); } } catch (error) { console.error(`Error resolving symlink ${fullPath}:`, error.message); } } else if (entry.isDirectory()) { // 递归处理子目录 resolveSymlinks(fullPath); } } } function copyDirectory(src, dest) { fs.mkdirSync(dest, { recursive: true }); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDirectory(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } } const standaloneDir = path.join( __dirname, "..", ".next", "standalone", "node_modules", ); if (fs.existsSync(standaloneDir)) { console.log("Resolving symlinks in standalone node_modules..."); resolveSymlinks(standaloneDir); console.log("Symlink resolution complete!"); } else { console.warn(`Standalone node_modules not found at: ${standaloneDir}`); console.warn("Skipping symlink resolution."); } ================================================ FILE: free-todo-frontend/scripts/tauri-copy-resources.js ================================================ const fs = require("node:fs"); const path = require("node:path"); function parseArgs() { const args = process.argv.slice(2); const result = {}; for (let i = 0; i < args.length; i += 1) { const key = args[i]; const value = args[i + 1]; if (key?.startsWith("--") && value && !value.startsWith("--")) { result[key.slice(2)] = value; i += 0; } } return result; } function copyDir(src, dest) { if (!fs.existsSync(src)) { console.warn(`Source not found, skipping: ${src}`); return; } fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true, force: true }); console.log(`Copied ${src} -> ${dest}`); } function findLatestReleaseDir(targetRoot) { if (!fs.existsSync(targetRoot)) { return null; } const entries = fs.readdirSync(targetRoot, { withFileTypes: true }); const candidates = []; for (const entry of entries) { if (!entry.isDirectory()) { continue; } const releaseDir = path.join(targetRoot, entry.name, "release"); if (fs.existsSync(releaseDir)) { const stat = fs.statSync(releaseDir); candidates.push({ dir: releaseDir, mtimeMs: stat.mtimeMs }); } } candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); return candidates[0]?.dir ?? null; } const args = parseArgs(); const rootDir = path.resolve(__dirname, ".."); const tauriTargetDir = path.join(rootDir, "src-tauri", "target"); let releaseDir = null; if (args.target) { releaseDir = path.join(tauriTargetDir, args.target, "release"); } else { const defaultRelease = path.join(tauriTargetDir, "release"); if (fs.existsSync(defaultRelease)) { releaseDir = defaultRelease; } else { releaseDir = findLatestReleaseDir(tauriTargetDir); } } if (!releaseDir || !fs.existsSync(releaseDir)) { console.error("Release directory not found. Did tauri build finish?"); process.exit(1); } const resourcesDir = path.join(releaseDir, "resources"); fs.mkdirSync(resourcesDir, { recursive: true }); const standaloneSrc = path.join(rootDir, ".next", "standalone"); const standaloneDest = path.join(resourcesDir, "standalone"); copyDir(standaloneSrc, standaloneDest); const backendSrc = path.join(rootDir, "..", "dist-backend"); const backendDest = path.join(resourcesDir, "dist-backend"); copyDir(backendSrc, backendDest); ================================================ FILE: free-todo-frontend/scripts/tauri-prebuild.js ================================================ const fs = require("node:fs"); const path = require("node:path"); const { execSync } = require("node:child_process"); const distDir = path.join(__dirname, "..", "src-tauri", "dist"); const indexPath = path.join(distDir, "index.html"); fs.mkdirSync(distDir, { recursive: true }); const html = ` FreeTodo

Starting FreeTodo...

Waiting for the local web server.

`; fs.writeFileSync(indexPath, html, "utf8"); console.log(`Wrote ${indexPath}`); function copyDir(src, dest) { if (!fs.existsSync(src)) { return; } fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true, force: true }); } const rootDir = path.join(__dirname, ".."); const nextDir = path.join(rootDir, ".next"); const standaloneDir = path.join(nextDir, "standalone"); if (fs.existsSync(standaloneDir)) { const staticSrc = path.join(nextDir, "static"); const staticDest = path.join(standaloneDir, ".next", "static"); const publicSrc = path.join(rootDir, "public"); const publicDest = path.join(standaloneDir, "public"); copyDir(staticSrc, staticDest); copyDir(publicSrc, publicDest); try { execSync("node scripts/resolve-symlinks.js", { cwd: rootDir, stdio: "inherit" }); } catch (error) { console.warn(`Failed to resolve symlinks: ${error.message}`); } try { execSync("node scripts/copy-missing-deps.js", { cwd: rootDir, stdio: "inherit" }); } catch (error) { console.warn(`Failed to copy missing deps: ${error.message}`); } } else { console.warn(`Standalone output not found at: ${standaloneDir}`); } ================================================ FILE: free-todo-frontend/src-tauri/.tauri-lint-dist/.gitkeep ================================================ ================================================ FILE: free-todo-frontend/src-tauri/Cargo.toml ================================================ [package] name = "free-todo" version = "0.1.2" description = "FreeTodo - Your Intelligent Todo Application" license-file = "../../LICENSE" repository = "https://github.com/FreeU-group/FreeTodo" edition = "2021" [build-dependencies] tauri-build = { version = "=2.5.5", features = [] } [dependencies] tauri = { version = "=2.9.1", features = ["tray-icon", "protocol-asset"] } tauri-plugin-shell = "=2.3.3" tauri-plugin-notification = "2.3.3" tauri-plugin-global-shortcut = "2.3.1" tokio = { version = "1.49.0", features = ["full"] } reqwest = { version = "0.13.1", features = ["json", "stream"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" log = "0.4.29" env_logger = "0.11.8" png = "0.18.0" axum = "0.8.8" rand = "0.9.2" flate2 = "1" tar = "0.4" zip = "7.2.0" futures-util = "0.3" [target.'cfg(unix)'.dependencies] libc = "0.2.180" [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] ================================================ FILE: free-todo-frontend/src-tauri/PACKAGING_GUIDE.md ================================================ # FreeTodo Tauri Packaging Guide (Web Mode) This document describes how to build and locate Tauri packaging outputs for the **Web mode** app. Island mode is **not packaged** yet (still in development). ## Table of Contents - [Quick Start](#quick-start) - [Build Outputs](#build-outputs) - [Build Notes](#build-notes) - [Troubleshooting](#troubleshooting) ## Quick Start Run from the repository root: ```bash cd free-todo-frontend # Web mode (default) pnpm build:tauri:web:full # Platform specific pnpm build:tauri:web:full:win pnpm build:tauri:web:full:mac pnpm build:tauri:web:full:linux ``` ## Build Outputs Tauri build artifacts are written under: ``` free-todo-frontend/src-tauri/target//bundle/ ``` Where `` is: - `release` for `tauri build` (default) - `debug` for `tauri build --debug` ### Windows (NSIS) ``` free-todo-frontend/src-tauri/target/release/bundle/nsis/ FreeTodo__x64-setup.exe ``` ### macOS (app / dmg) ``` free-todo-frontend/src-tauri/target/release/bundle/macos/ FreeTodo.app FreeTodo__universal.dmg ``` ### Linux (AppImage / deb) ``` free-todo-frontend/src-tauri/target/release/bundle/ appimage/FreeTodo__amd64.AppImage deb/free-todo__amd64.deb ``` ## Build Notes ### Web Mode Only Current Tauri configuration builds **Web mode only**: - Standard window (1200x800) - With window decorations - Non-transparent Island mode is not packaged by default. ### Frontend Assets Tauri uses a local loading page: ``` free-todo-frontend/src-tauri/dist/index.html ``` This page redirects to the running Next.js server. ### Next.js Build The build command runs: ``` pnpm build:frontend:web ``` Next.js artifacts: ``` free-todo-frontend/.next/ ``` ## Troubleshooting ### Where is the app after build? Check: ``` free-todo-frontend/src-tauri/target/release/bundle/ ``` ### Build uses the wrong window mode Tauri currently packages **Web mode only**. Island mode is intentionally excluded. --- **Last Updated**: 2026-01-29 ================================================ FILE: free-todo-frontend/src-tauri/build.rs ================================================ fn main() { tauri_build::build() } ================================================ FILE: free-todo-frontend/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: free-todo-frontend/src-tauri/icons/android/values/ic_launcher_background.xml ================================================ #fff ================================================ FILE: free-todo-frontend/src-tauri/rust-toolchain.toml ================================================ [toolchain] channel = "stable" components = ["rustfmt", "clippy"] ================================================ FILE: free-todo-frontend/src-tauri/rustfmt.toml ================================================ # Rust formatting configuration # See: https://rust-lang.github.io/rustfmt/ # Maximum line width max_width = 100 # Use spaces for indentation hard_tabs = false tab_spaces = 4 # Edition edition = "2021" # Imports organization (stable options only) reorder_imports = true # Formatting newline_style = "Auto" use_small_heuristics = "Default" ================================================ FILE: free-todo-frontend/src-tauri/src/backend.rs ================================================ //! Python Backend Sidecar Management //! //! This module handles the lifecycle of the Python backend server, //! including starting, health checking, proxying, and stopping the process. use crate::backend_log::{emit_backend_log, format_download_progress, spawn_log_reader}; use crate::backend_paths::{ get_backend_path, get_backend_script_entry, get_backend_script_root, get_data_dir, get_requirements_path, get_runtime_root, }; use crate::backend_proxy::{start_proxy_server, ProxyState}; use crate::backend_python::{ ensure_uv, ensure_uv_binary_with_progress, ensure_uv_python, ensure_uv_venv, ensure_venv, find_python312, install_requirements, uv_env_pairs, }; use crate::backend_support::{ check_backend_health as check_backend_health_with_timeout, detect_running_backend_port, is_lifetrace_backend, pick_backend_port, verify_backend_mode, wait_for_backend, }; use crate::config::{self, timeouts, ServerMode}; use log::{error, info, warn}; use std::path::Path; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use tauri::AppHandle; struct BackendState { backend_port: Arc, ready: Arc, proxy_port: AtomicU16, stopping: AtomicBool, proxy_started: AtomicBool, process: Mutex>, uv_synced: AtomicBool, } static STATE: OnceLock = OnceLock::new(); fn state() -> &'static BackendState { STATE.get_or_init(|| BackendState { backend_port: Arc::new(AtomicU16::new(0)), ready: Arc::new(AtomicBool::new(false)), proxy_port: AtomicU16::new(0), stopping: AtomicBool::new(false), proxy_started: AtomicBool::new(false), process: Mutex::new(None), uv_synced: AtomicBool::new(false), }) } /// Backend runtime type #[derive(Debug, Clone, Copy, PartialEq)] enum BackendRuntime { Uv, Script, PyInstaller, } /// Determine backend runtime from env or build-time default fn get_backend_runtime() -> BackendRuntime { if let Ok(value) = std::env::var("FREETODO_BACKEND_RUNTIME") { let normalized = value.to_lowercase(); if normalized == "uv" || normalized == "uv-run" || normalized == "uvrun" { return BackendRuntime::Uv; } if normalized == "pyinstaller" { return BackendRuntime::PyInstaller; } if normalized == "script" { return BackendRuntime::Script; } } if let Some(value) = option_env!("FREETODO_BACKEND_RUNTIME") { if value.eq_ignore_ascii_case("pyinstaller") { return BackendRuntime::PyInstaller; } if value.eq_ignore_ascii_case("script") { return BackendRuntime::Script; } if value.eq_ignore_ascii_case("uv") || value.eq_ignore_ascii_case("uv-run") { return BackendRuntime::Uv; } } BackendRuntime::Uv } fn run_uv_sync_if_needed(backend_root: &Path) -> Result<(), String> { let state = state(); if state.uv_synced.load(Ordering::Relaxed) { return Ok(()); } let mut cmd = Command::new("uv"); cmd.arg("sync").current_dir(backend_root); for (key, value) in uv_env_pairs() { cmd.env(key, value); } let status = cmd .status() .map_err(|e| format!("Failed to run uv sync: {}", e))?; if status.success() { state.uv_synced.store(true, Ordering::Relaxed); Ok(()) } else { Err(format!("uv sync failed with status {}", status)) } } fn server_mode() -> ServerMode { ServerMode::current() } fn mode_label(mode: ServerMode) -> &'static str { match mode { ServerMode::Dev => "dev", ServerMode::Build => "build", } } const BACKEND_LOG_LABEL: &str = "backend"; /// Get the backend URL (proxy port) pub fn get_backend_url() -> String { let port = state().proxy_port.load(Ordering::Relaxed); let port = if port == 0 { config::ports::backend_port(server_mode()) } else { port }; format!("http://127.0.0.1:{}", port) } /// Check backend health pub async fn check_backend_health( port: u16, ) -> Result> { check_backend_health_with_timeout(port, timeouts::HEALTH_CHECK).await } /// Start the Python backend server (with proxy) pub async fn start_backend( app: &AppHandle, ) -> Result<(), Box> { let state = state(); let mode = server_mode(); let proxy_port = config::ports::backend_port(mode); state.stopping.store(false, Ordering::Relaxed); state.backend_port.store(0, Ordering::Relaxed); state.ready.store(false, Ordering::Relaxed); state.proxy_port.store(proxy_port, Ordering::Relaxed); if !state.proxy_started.swap(true, Ordering::Relaxed) { let proxy_state = ProxyState::new(state.backend_port.clone(), state.ready.clone()); if let Err(err) = start_proxy_server(proxy_port, proxy_state).await { state.proxy_started.store(false, Ordering::Relaxed); if is_lifetrace_backend(proxy_port).await { warn!( "Proxy port {} already has a backend instance, using it directly", proxy_port ); state.backend_port.store(proxy_port, Ordering::Relaxed); state.ready.store(true, Ordering::Relaxed); } else { return Err(err.into()); } } } let app_handle = app.clone(); tokio::spawn(async move { if let Err(err) = backend_supervisor(app_handle, mode).await { error!("Backend supervisor exited: {}", err); } }); Ok(()) } async fn backend_supervisor(app: AppHandle, mode: ServerMode) -> Result<(), String> { let state = state(); let mut backoff = Duration::from_millis(500); let max_backoff = Duration::from_secs(10); let interval = Duration::from_millis(config::health_check::BACKEND_INTERVAL); loop { if state.stopping.load(Ordering::Relaxed) { break; } let mut exited = false; let mut managed = false; { let mut guard = state.process.lock().unwrap(); if let Some(child) = guard.as_mut() { managed = true; match child.try_wait() { Ok(Some(status)) => { warn!("Backend exited: {}", status); *guard = None; exited = true; } Ok(None) => {} Err(err) => { warn!("Failed to check backend status: {}", err); } } } } if exited { state.ready.store(false, Ordering::Relaxed); state.backend_port.store(0, Ordering::Relaxed); } let backend_port = state.backend_port.load(Ordering::Relaxed); if managed { if backend_port != 0 { let healthy = check_backend_health(backend_port).await.unwrap_or(false); state.ready.store(healthy, Ordering::Relaxed); if !healthy { warn!("Backend health check failed"); } } tokio::time::sleep(interval).await; continue; } if backend_port != 0 { let healthy = check_backend_health(backend_port).await.unwrap_or(false); if healthy { state.ready.store(true, Ordering::Relaxed); tokio::time::sleep(interval).await; continue; } state.ready.store(false, Ordering::Relaxed); state.backend_port.store(0, Ordering::Relaxed); } if let Some(port) = detect_running_backend_port(mode).await { state.backend_port.store(port, Ordering::Relaxed); state.ready.store(true, Ordering::Relaxed); backoff = Duration::from_millis(500); tokio::time::sleep(interval).await; continue; } match start_backend_process(&app, mode).await { Ok(port) => { state.backend_port.store(port, Ordering::Relaxed); state.ready.store(true, Ordering::Relaxed); backoff = Duration::from_millis(500); emit_backend_log(&app, format!("Backend ready on port {}", port)); } Err(err) => { state.ready.store(false, Ordering::Relaxed); warn!("Failed to start backend: {}", err); emit_backend_log(&app, format!("Backend start failed: {}", err)); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(max_backoff); } } tokio::time::sleep(interval).await; } Ok(()) } async fn start_backend_process(app: &AppHandle, mode: ServerMode) -> Result { let state = state(); let backend_runtime = get_backend_runtime(); let port = pick_backend_port(mode)?; let mode_label = mode_label(mode); state.ready.store(false, Ordering::Relaxed); let backend_path = if backend_runtime == BackendRuntime::PyInstaller { get_backend_path(app).map_err(|e| { warn!("Backend executable not found: {}", e); e })? } else { let backend_root = get_backend_script_root(app)?; get_backend_script_entry(&backend_root) }; let data_dir = get_data_dir(app, mode)?; let mut backend_workdir = backend_path.parent().unwrap_or(&backend_path).to_path_buf(); let mut command = if backend_runtime == BackendRuntime::Uv { emit_backend_log(app, "Starting backend with uv runtime..."); let backend_root = get_backend_script_root(app)?; backend_workdir = backend_root; run_uv_sync_if_needed(&backend_workdir)?; let mut cmd = Command::new("uv"); cmd.args([ "run", "python", "-m", "lifetrace.server", "--port", &port.to_string(), "--mode", mode_label, ]); for (key, value) in uv_env_pairs() { cmd.env(key, value); } cmd } else if backend_runtime == BackendRuntime::Script { emit_backend_log(app, "Preparing script runtime environment..."); let runtime_root = get_runtime_root(app)?; let venv_dir = runtime_root.join("python-venv"); let backend_root = get_backend_script_root(app)?; let requirements_path = get_requirements_path(&backend_root); if !requirements_path.exists() { return Err(format!( "Requirements file not found at {:?}", requirements_path )); } let mut venv_python = None; emit_backend_log(app, "Ensuring uv binary is available..."); match ensure_uv_binary_with_progress(&runtime_root, |progress| { emit_backend_log(app, format_download_progress(&progress)); }) .await { Ok(uv_path) => { emit_backend_log(app, format!("uv ready at {}", uv_path.display())); emit_backend_log(app, "Ensuring Python 3.12 via uv..."); if let Err(err) = ensure_uv_python(uv_path.as_path()) { emit_backend_log(app, format!("uv python install failed: {}", err)); } else { emit_backend_log(app, "uv Python install completed."); emit_backend_log(app, "Creating virtual environment with uv..."); match ensure_uv_venv(uv_path.as_path(), venv_dir.as_path()) { Ok(path) => { emit_backend_log(app, "uv venv created."); emit_backend_log(app, "Installing backend dependencies with uv..."); if let Err(err) = install_requirements( uv_path.as_path(), path.as_path(), requirements_path.as_path(), ) { emit_backend_log( app, format!("uv dependency install failed: {}", err), ); } else { emit_backend_log(app, "uv dependency install completed."); venv_python = Some(path); } } Err(err) => { emit_backend_log(app, format!("uv venv creation failed: {}", err)); } } } } Err(err) => { emit_backend_log(app, format!("uv download failed: {}", err)); } } if venv_python.is_none() { emit_backend_log(app, "Falling back to system Python 3.12..."); let system_python = find_python312().ok_or("Python 3.12 not found")?; let fallback_python = ensure_venv(system_python.as_path(), venv_dir.as_path())?; emit_backend_log(app, "Installing uv in virtual environment..."); let uv_path = ensure_uv(fallback_python.as_path(), venv_dir.as_path())?; emit_backend_log(app, "Installing backend dependencies with uv..."); install_requirements( uv_path.as_path(), fallback_python.as_path(), requirements_path.as_path(), )?; emit_backend_log(app, "uv dependency install completed."); venv_python = Some(fallback_python); } let venv_python = venv_python.ok_or("Failed to prepare Python runtime")?; if !venv_python.exists() { return Err("Virtual environment python not found".to_string()); } backend_workdir = backend_root; let mut cmd = Command::new(venv_python); cmd.arg(&backend_path); cmd } else { Command::new(&backend_path) }; if backend_runtime != BackendRuntime::Uv { command.args([ "--port", &port.to_string(), "--data-dir", data_dir.to_str().unwrap_or(""), "--mode", mode_label, ]); } command .current_dir(backend_workdir) .env("PYTHONUNBUFFERED", "1") .env("PYTHONUTF8", "1") .env("LIFETRACE_DATA_DIR", data_dir.to_str().unwrap_or("")) .env("LIFETRACE__OBSERVABILITY__ENABLED", "false") .env("LIFETRACE__SERVER__DEBUG", "false"); info!("Starting backend server on port {}", port); info!("Backend runtime: {:?}", backend_runtime); info!("Backend path: {:?}", backend_path); info!("Data directory: {:?}", data_dir); info!("Server mode: {}", mode_label); let mut child = command .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("Failed to start backend: {}", e))?; if let Some(stdout) = child.stdout.take() { spawn_log_reader(app.clone(), stdout, BACKEND_LOG_LABEL); } if let Some(stderr) = child.stderr.take() { spawn_log_reader(app.clone(), stderr, BACKEND_LOG_LABEL); } { let mut guard = state.process.lock().unwrap(); *guard = Some(child); } info!("Waiting for backend server to be ready..."); if let Err(err) = wait_for_backend( port, timeouts::BACKEND_READY / 1000, timeouts::HEALTH_CHECK, timeouts::HEALTH_CHECK_RETRY, ) .await { stop_managed_backend(); emit_backend_log(app, format!("Backend failed to become ready: {}", err)); return Err(err); } info!("Backend server is ready at http://127.0.0.1:{}", port); if let Err(err) = verify_backend_mode(port, mode_label).await { stop_managed_backend(); return Err(err); } Ok(port) } fn stop_managed_backend() { let state = state(); let mut guard = state.process.lock().unwrap(); if let Some(child) = guard.take() { #[cfg(unix)] { unsafe { libc::kill(child.id() as i32, libc::SIGTERM); } } #[cfg(windows)] { let mut child = child; let _ = child.kill(); } } } /// Stop the backend server pub fn stop_backend() { let state = state(); state.stopping.store(true, Ordering::Relaxed); state.ready.store(false, Ordering::Relaxed); let mut guard = state.process.lock().unwrap(); if let Some(mut child) = guard.take() { info!("Stopping backend server..."); // Try graceful shutdown first #[cfg(unix)] { unsafe { libc::kill(child.id() as i32, libc::SIGTERM); } } #[cfg(windows)] { let _ = child.kill(); } // Wait a bit for graceful shutdown std::thread::sleep(Duration::from_secs(2)); // Force kill if still running match child.try_wait() { Ok(Some(_)) => { info!("Backend server stopped gracefully"); } Ok(None) => { warn!("Backend server did not stop gracefully, forcing kill"); let _ = child.kill(); } Err(e) => { error!("Error checking backend status: {}", e); let _ = child.kill(); } } } } /// Cleanup on application exit pub fn cleanup() { stop_backend(); } ================================================ FILE: free-todo-frontend/src-tauri/src/backend_log.rs ================================================ //! Backend logging helpers use crate::backend_python::DownloadProgress; use log::info; use std::io::{BufRead, BufReader}; use tauri::{AppHandle, Emitter}; pub fn emit_backend_log(app: &AppHandle, message: impl Into) { let message = message.into(); info!("backend-log: {}", message); let _ = app.emit("backend-log", message); } pub fn spawn_log_reader( app: AppHandle, stream: impl std::io::Read + Send + 'static, label: &'static str, ) { std::thread::spawn(move || { let reader = BufReader::new(stream); for line in reader.lines().map_while(Result::ok) { emit_backend_log(&app, format!("[{}] {}", label, line)); } }); } pub fn format_download_progress(progress: &DownloadProgress) -> String { match progress.total_bytes { Some(total) if total > 0 => { let percent = ((progress.received_bytes * 100) / total).min(100); format!( "Downloading uv: {}% ({} / {})", percent, format_bytes(progress.received_bytes), format_bytes(total) ) } _ => format!("Downloading uv: {}", format_bytes(progress.received_bytes)), } } fn format_bytes(bytes: u64) -> String { const KB: f64 = 1024.0; const MB: f64 = KB * 1024.0; const GB: f64 = MB * 1024.0; let value = bytes as f64; if value >= GB { format!("{:.1} GB", value / GB) } else if value >= MB { format!("{:.1} MB", value / MB) } else if value >= KB { format!("{:.1} KB", value / KB) } else { format!("{} B", bytes) } } ================================================ FILE: free-todo-frontend/src-tauri/src/backend_paths.rs ================================================ //! Backend path resolution helpers use crate::config::{process, ServerMode}; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; /// Get backend executable path for PyInstaller runtime pub fn get_backend_path(app: &AppHandle) -> Result { let resource_path = app .path() .resource_dir() .map_err(|e| format!("Failed to get resource dir: {}", e))?; let packaged_backend = resource_path .join("backend") .join(process::BACKEND_EXEC_NAME); if packaged_backend.exists() { return Ok(packaged_backend); } let packaged_dist = resource_path .join("dist-backend") .join(process::BACKEND_EXEC_NAME); if packaged_dist.exists() { return Ok(packaged_dist); } // Development mode: try dist-backend let dev_path = std::env::current_dir() .map_err(|e| format!("Failed to get current dir: {}", e))? .parent() .ok_or("Failed to get parent dir")? .join("dist-backend") .join(process::BACKEND_EXEC_NAME); if dev_path.exists() { Ok(dev_path) } else { Err(format!( "Backend executable not found at {:?} or {:?} or {:?}", packaged_backend, packaged_dist, dev_path )) } } /// Locate backend script root (for script runtime) pub fn get_backend_script_root(app: &AppHandle) -> Result { let resource_path = app .path() .resource_dir() .map_err(|e| format!("Failed to get resource dir: {}", e))?; let candidates = [ resource_path.join("backend"), resource_path.join("lifetrace"), ]; for candidate in candidates { let script_path = candidate .join("lifetrace") .join("scripts") .join("start_backend.py"); if script_path.exists() { return Ok(candidate); } let direct_script = candidate.join("scripts").join("start_backend.py"); if direct_script.exists() { return Ok(candidate); } } // Development fallback let dev_root = std::env::current_dir() .map_err(|e| format!("Failed to get current dir: {}", e))? .parent() .ok_or("Failed to get parent dir")? .to_path_buf(); if dev_root .join("lifetrace") .join("scripts") .join("start_backend.py") .exists() { return Ok(dev_root); } Err("Backend script not found in resources".to_string()) } pub fn get_backend_script_entry(root: &Path) -> PathBuf { let nested = root .join("lifetrace") .join("scripts") .join("start_backend.py"); if nested.exists() { return nested; } root.join("scripts").join("start_backend.py") } pub fn get_requirements_path(root: &Path) -> PathBuf { let nested = root.join("requirements-runtime.txt"); if nested.exists() { return nested; } if let Some(parent) = root.parent() { let parent_req = parent.join("requirements-runtime.txt"); if parent_req.exists() { return parent_req; } } let fallback = root.join("backend").join("requirements-runtime.txt"); if fallback.exists() { return fallback; } nested } pub fn get_runtime_root(app: &AppHandle) -> Result { let data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))?; let runtime_dir = data_dir.join("runtime"); if !runtime_dir.exists() { std::fs::create_dir_all(&runtime_dir) .map_err(|e| format!("Failed to create runtime dir: {}", e))?; } Ok(runtime_dir) } /// Get data directory for backend pub fn get_data_dir(app: &AppHandle, mode: ServerMode) -> Result { let app_data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))?; let legacy_dir = app_data_dir.join(process::BACKEND_DATA_DIR); let mode_suffix = match mode { ServerMode::Dev => "dev", ServerMode::Build => "build", }; let mode_dir = app_data_dir.join(format!("{}-{}", process::BACKEND_DATA_DIR, mode_suffix)); let data_dir = if mode == ServerMode::Build && legacy_dir.exists() { legacy_dir } else { mode_dir }; if !data_dir.exists() { std::fs::create_dir_all(&data_dir) .map_err(|e| format!("Failed to create data dir: {}", e))?; } Ok(data_dir) } ================================================ FILE: free-todo-frontend/src-tauri/src/backend_proxy.rs ================================================ //! Backend proxy server for stable frontend ports. use axum::{ body::{to_bytes, Body}, extract::State, http::{header, Request, StatusCode}, response::Response, Router, }; use log::warn; use reqwest::Client; use serde_json::json; use std::sync::{ atomic::{AtomicBool, AtomicU16, Ordering}, Arc, }; use std::time::Duration; #[derive(Clone)] pub struct ProxyState { backend_port: Arc, ready: Arc, client: Client, } impl ProxyState { pub fn new(backend_port: Arc, ready: Arc) -> Self { let client = Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap_or_default(); Self { backend_port, ready, client, } } } pub async fn start_proxy_server(port: u16, state: ProxyState) -> Result<(), String> { let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) .await .map_err(|e| format!("Failed to bind proxy port {}: {}", port, e))?; let app = Router::new().fallback(proxy_handler).with_state(state); tokio::spawn(async move { if let Err(err) = axum::serve(listener, app).await { warn!("Proxy server exited: {}", err); } }); Ok(()) } async fn proxy_handler(State(state): State, req: Request) -> Response { let path = req.uri().path(); if path == "/ready" { let backend_port = state.backend_port.load(Ordering::Relaxed); let ready = state.ready.load(Ordering::Relaxed); return ready_response(ready, backend_port); } let backend_port = state.backend_port.load(Ordering::Relaxed); let ready = state.ready.load(Ordering::Relaxed); if backend_port == 0 || !ready { return ready_response(false, backend_port); } let path_and_query = req .uri() .path_and_query() .map(|value| value.as_str()) .unwrap_or("/"); let url = format!("http://127.0.0.1:{}{}", backend_port, path_and_query); let (parts, body) = req.into_parts(); let mut builder = state.client.request(parts.method, &url); for (name, value) in parts.headers.iter() { if should_skip_request_header(name) { continue; } builder = builder.header(name, value); } let body_bytes = match to_bytes(body, usize::MAX).await { Ok(bytes) => bytes, Err(err) => { warn!("Proxy body read failed: {}", err); return ready_response(false, backend_port); } }; match builder.body(body_bytes).send().await { Ok(response) => { let status = response.status(); let headers = response.headers().clone(); let bytes = match response.bytes().await { Ok(body) => body, Err(err) => { warn!("Proxy response read failed: {}", err); return ready_response(false, backend_port); } }; let mut builder = Response::builder().status(status); for (name, value) in headers.iter() { if should_skip_response_header(name) { continue; } builder = builder.header(name, value); } builder = builder.header(header::CONTENT_LENGTH, bytes.len().to_string()); builder .body(Body::from(bytes)) .unwrap_or_else(|_| ready_response(false, backend_port)) } Err(err) => { warn!("Proxy request failed: {}", err); ready_response(false, backend_port) } } } fn ready_response(ready: bool, backend_port: u16) -> Response { let payload = if ready { json!({ "status": "ready", "backend_port": backend_port, }) } else { json!({ "status": "starting", }) }; let mut response = Response::new(Body::from(payload.to_string())); *response.status_mut() = if ready { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE }; response.headers_mut().insert( header::CONTENT_TYPE, header::HeaderValue::from_static("application/json"), ); response } fn should_skip_request_header(name: &header::HeaderName) -> bool { *name == header::HOST || *name == header::CONTENT_LENGTH || *name == header::CONNECTION } fn should_skip_response_header(name: &header::HeaderName) -> bool { *name == header::CONTENT_LENGTH || *name == header::TRANSFER_ENCODING || *name == header::CONTENT_ENCODING || *name == header::CONNECTION } ================================================ FILE: free-todo-frontend/src-tauri/src/backend_python.rs ================================================ //! Python runtime helpers for backend bootstrap use futures_util::StreamExt; use serde::Deserialize; use std::fs; use std::io; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; const UV_PYTHON_VERSION: &str = "3.12"; enum UvArchiveKind { Zip, TarGz, } #[derive(Deserialize)] struct PythonInfo { version: String, executable: String, } pub fn get_venv_python_path(venv_dir: &Path) -> PathBuf { if cfg!(windows) { return venv_dir.join("Scripts").join("python.exe"); } venv_dir.join("bin").join("python3") } fn get_venv_uv_path(venv_dir: &Path) -> PathBuf { if cfg!(windows) { return venv_dir.join("Scripts").join("uv.exe"); } venv_dir.join("bin").join("uv") } fn is_mainland_china() -> bool { if let Ok(value) = std::env::var("FREETODO_REGION") { let normalized = value.to_lowercase(); if normalized == "cn" { return true; } if normalized == "global" || normalized == "intl" { return false; } } if let Ok(lang) = std::env::var("LANG") { if lang.to_lowercase().starts_with("zh_cn") { return true; } } false } fn build_uv_env() -> Vec<(String, String)> { if is_mainland_china() { vec![ ( "UV_INDEX_URL".to_string(), "https://pypi.tuna.tsinghua.edu.cn/simple".to_string(), ), ( "UV_EXTRA_INDEX_URL".to_string(), "https://pypi.org/simple".to_string(), ), ( "PIP_INDEX_URL".to_string(), "https://pypi.tuna.tsinghua.edu.cn/simple".to_string(), ), ( "PIP_EXTRA_INDEX_URL".to_string(), "https://pypi.org/simple".to_string(), ), ] } else { vec![ ( "UV_INDEX_URL".to_string(), "https://pypi.org/simple".to_string(), ), ( "PIP_INDEX_URL".to_string(), "https://pypi.org/simple".to_string(), ), ] } } pub fn uv_env_pairs() -> Vec<(String, String)> { build_uv_env() } pub fn get_runtime_uv_path(runtime_root: &Path) -> PathBuf { if cfg!(windows) { return runtime_root.join("uv").join("uv.exe"); } runtime_root.join("uv").join("uv") } pub struct DownloadProgress { pub received_bytes: u64, pub total_bytes: Option, } fn uv_archive_kind() -> Result { if cfg!(windows) { return Ok(UvArchiveKind::Zip); } if cfg!(target_os = "macos") || cfg!(target_os = "linux") { return Ok(UvArchiveKind::TarGz); } Err("Unsupported OS for uv download".to_string()) } fn uv_download_url() -> Result<&'static str, String> { if cfg!(windows) { if cfg!(target_arch = "x86_64") { return Ok( "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip", ); } if cfg!(target_arch = "aarch64") { return Ok( "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-pc-windows-msvc.zip", ); } return Err("Unsupported Windows architecture for uv download".to_string()); } if cfg!(target_os = "macos") { if cfg!(target_arch = "x86_64") { return Ok( "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz", ); } if cfg!(target_arch = "aarch64") { return Ok( "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz", ); } return Err("Unsupported macOS architecture for uv download".to_string()); } if cfg!(target_os = "linux") { if cfg!(target_arch = "x86_64") { return Ok( "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz", ); } if cfg!(target_arch = "aarch64") { return Ok( "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-unknown-linux-gnu.tar.gz", ); } return Err("Unsupported Linux architecture for uv download".to_string()); } Err("Unsupported OS for uv download".to_string()) } fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<(), String> { let file = fs::File::open(archive_path).map_err(|e| e.to_string())?; let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?; for i in 0..archive.len() { let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; let outpath = dest_dir.join(entry.mangled_name()); if entry.is_dir() { fs::create_dir_all(&outpath).map_err(|e| e.to_string())?; } else { if let Some(parent) = outpath.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?; io::copy(&mut entry, &mut outfile).map_err(|e| e.to_string())?; } } Ok(()) } fn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<(), String> { let file = fs::File::open(archive_path).map_err(|e| e.to_string())?; let decompressor = flate2::read::GzDecoder::new(file); let mut archive = tar::Archive::new(decompressor); archive.unpack(dest_dir).map_err(|e| e.to_string())?; Ok(()) } fn find_uv_binary(root: &Path) -> Option { let filename = if cfg!(windows) { "uv.exe" } else { "uv" }; let direct = root.join(filename); if direct.exists() { return Some(direct); } let entries = fs::read_dir(root).ok()?; for entry in entries { let entry = entry.ok()?; let path = entry.path(); if path.is_dir() { let nested = path.join(filename); if nested.exists() { return Some(nested); } } } None } async fn download_with_progress( url: &str, archive_path: &Path, mut progress: F, ) -> Result<(), String> where F: FnMut(DownloadProgress) + Send, { let response = reqwest::get(url) .await .map_err(|e| format!("Failed to download uv: {}", e))?; if !response.status().is_success() { return Err(format!( "Failed to download uv (status {})", response.status() )); } let total = response.content_length(); let mut stream = response.bytes_stream(); let mut file = fs::File::create(archive_path).map_err(|e| format!("Failed to save uv archive: {}", e))?; let mut received: u64 = 0; let mut last_percent: Option = None; let mut last_emit_bytes: u64 = 0; while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|e| format!("Failed to read uv archive: {}", e))?; file.write_all(&chunk) .map_err(|e| format!("Failed to write uv archive: {}", e))?; received += chunk.len() as u64; let percent = total.map(|t| ((received * 100) / t).min(100) as u8); let should_emit = match percent { Some(value) => last_percent != Some(value), None => received.saturating_sub(last_emit_bytes) >= 1_048_576, }; if should_emit { progress(DownloadProgress { received_bytes: received, total_bytes: total, }); last_percent = percent; last_emit_bytes = received; } } progress(DownloadProgress { received_bytes: received, total_bytes: total, }); Ok(()) } pub async fn ensure_uv_binary_with_progress( runtime_root: &Path, progress: F, ) -> Result where F: FnMut(DownloadProgress) + Send, { let uv_path = get_runtime_uv_path(runtime_root); if uv_path.exists() { return Ok(uv_path); } let uv_dir = uv_path .parent() .ok_or("Invalid uv path for runtime directory")?; fs::create_dir_all(uv_dir).map_err(|e| format!("Failed to create uv dir: {}", e))?; let url = uv_download_url()?; let archive_kind = uv_archive_kind()?; let archive_path = match archive_kind { UvArchiveKind::Zip => uv_dir.join("uv.zip"), UvArchiveKind::TarGz => uv_dir.join("uv.tar.gz"), }; download_with_progress(url, &archive_path, progress).await?; match archive_kind { UvArchiveKind::Zip => extract_zip(&archive_path, uv_dir)?, UvArchiveKind::TarGz => extract_tar_gz(&archive_path, uv_dir)?, } let _ = fs::remove_file(&archive_path); let uv_path = if uv_path.exists() { uv_path } else { find_uv_binary(uv_dir).ok_or("uv binary not found after extraction")? }; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&uv_path) .map_err(|e| format!("Failed to read uv permissions: {}", e))? .permissions(); perms.set_mode(0o755); fs::set_permissions(&uv_path, perms) .map_err(|e| format!("Failed to set uv permissions: {}", e))?; } Ok(uv_path) } fn run_command(command: &str, args: &[&str], envs: &[(&str, &str)]) -> Result { let mut cmd = Command::new(command); cmd.args(args); for (key, value) in envs { cmd.env(key, value); } let output = cmd.output().map_err(|e| e.to_string())?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } else { Err(String::from_utf8_lossy(&output.stderr).to_string()) } } fn get_python_info(command: &str, args: &[&str]) -> Option { let mut full_args = args.to_vec(); full_args.extend_from_slice(&[ "-c", "import json, sys; print(json.dumps({'version': f'{sys.version_info[0]}.{sys.version_info[1]}', 'executable': sys.executable}))", ]); let output = run_command(command, &full_args, &[]).ok()?; let line = output.lines().next()?.trim(); serde_json::from_str(line).ok() } pub fn find_python312() -> Option { let mut candidates: Vec<(&str, Vec<&str>)> = Vec::new(); if cfg!(windows) { candidates.push(("py", vec!["-3.12"])); candidates.push(("python3.12", vec![])); candidates.push(("python", vec![])); } else { candidates.push(("python3.12", vec![])); candidates.push(("python3", vec![])); candidates.push(("python", vec![])); } for (command, args) in candidates { if let Some(info) = get_python_info(command, &args) { if info.version == "3.12" && !info.executable.is_empty() { return Some(PathBuf::from(info.executable)); } } } None } pub fn ensure_venv(python_path: &Path, venv_dir: &Path) -> Result { let venv_python = get_venv_python_path(venv_dir); if venv_python.exists() { return Ok(venv_python); } std::fs::create_dir_all(venv_dir).map_err(|e| format!("Failed to create venv dir: {}", e))?; run_command( python_path.to_str().ok_or("Invalid python path")?, &["-m", "venv", venv_dir.to_str().ok_or("Invalid venv path")?], &[], )?; if venv_python.exists() { Ok(venv_python) } else { Err("Failed to create virtual environment".to_string()) } } pub fn ensure_uv(venv_python: &Path, venv_dir: &Path) -> Result { let uv_path = get_venv_uv_path(venv_dir); if uv_path.exists() { return Ok(uv_path); } run_command( venv_python.to_str().ok_or("Invalid venv python path")?, &["-m", "pip", "install", "--upgrade", "uv"], &[], )?; if uv_path.exists() { Ok(uv_path) } else { Err("Failed to install uv in virtual environment".to_string()) } } pub fn ensure_uv_python(uv_path: &Path) -> Result<(), String> { let env_pairs = build_uv_env(); let env_refs: Vec<(&str, &str)> = env_pairs .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); run_command( uv_path.to_str().ok_or("Invalid uv path")?, &["python", "install", UV_PYTHON_VERSION], &env_refs, )?; Ok(()) } pub fn ensure_uv_venv(uv_path: &Path, venv_dir: &Path) -> Result { let venv_python = get_venv_python_path(venv_dir); if venv_python.exists() { return Ok(venv_python); } fs::create_dir_all(venv_dir).map_err(|e| format!("Failed to create venv dir: {}", e))?; let env_pairs = build_uv_env(); let env_refs: Vec<(&str, &str)> = env_pairs .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); run_command( uv_path.to_str().ok_or("Invalid uv path")?, &[ "venv", venv_dir.to_str().ok_or("Invalid venv path")?, "--python", UV_PYTHON_VERSION, ], &env_refs, )?; if venv_python.exists() { Ok(venv_python) } else { Err("Failed to create virtual environment with uv".to_string()) } } pub fn install_requirements( uv_path: &Path, venv_python: &Path, requirements_path: &Path, ) -> Result<(), String> { let env_pairs = build_uv_env(); let env_refs: Vec<(&str, &str)> = env_pairs .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); run_command( uv_path.to_str().ok_or("Invalid uv path")?, &[ "pip", "install", "-r", requirements_path .to_str() .ok_or("Invalid requirements path")?, "--python", venv_python.to_str().ok_or("Invalid venv python path")?, ], &env_refs, )?; Ok(()) } ================================================ FILE: free-todo-frontend/src-tauri/src/backend_support.rs ================================================ //! Backend helper utilities (health checks, port selection, detection). use crate::config::{self, ServerMode}; use log::info; use rand::Rng; use reqwest::Client; use serde::Deserialize; use std::net::TcpListener; use std::time::Duration; /// Health check response structure #[derive(Deserialize, Debug)] struct HealthResponse { app: Option, server_mode: Option, } fn backend_port_range(mode: ServerMode) -> (u16, u16) { match mode { ServerMode::Dev => ( config::ports::DEV_BACKEND_RANGE_START, config::ports::DEV_BACKEND_RANGE_END, ), ServerMode::Build => ( config::ports::BUILD_BACKEND_RANGE_START, config::ports::BUILD_BACKEND_RANGE_END, ), } } pub async fn is_lifetrace_backend(port: u16) -> bool { let url = format!("http://127.0.0.1:{}/health", port); let client = Client::builder() .timeout(Duration::from_secs(2)) .build() .unwrap_or_default(); match client.get(&url).send().await { Ok(response) => { if response.status().is_success() { if let Ok(health) = response.json::().await { return health.app.as_deref() == Some("lifetrace"); } } false } Err(_) => false, } } pub async fn check_backend_health( port: u16, timeout_ms: u64, ) -> Result> { let url = format!("http://127.0.0.1:{}/health", port); let client = Client::builder() .timeout(Duration::from_millis(timeout_ms)) .build()?; match client.get(&url).send().await { Ok(response) => Ok(response.status().is_success()), Err(_) => Ok(false), } } pub async fn detect_running_backend_port(mode: ServerMode) -> Option { let (start_port, end_port) = backend_port_range(mode); for port in start_port..=end_port { if is_lifetrace_backend(port).await { info!("Detected backend running on port: {}", port); return Some(port); } } None } fn port_available(port: u16) -> bool { TcpListener::bind(("127.0.0.1", port)).is_ok() } pub fn pick_backend_port(mode: ServerMode) -> Result { let (start_port, end_port) = backend_port_range(mode); let mut rng = rand::rng(); for _ in 0..10 { let port = rng.random_range(start_port..=end_port); if port_available(port) { return Ok(port); } } for port in start_port..=end_port { if port_available(port) { return Ok(port); } } Err(format!( "No available backend port in range {}-{}", start_port, end_port )) } pub async fn wait_for_backend( port: u16, timeout_secs: u64, health_timeout_ms: u64, retry_ms: u64, ) -> Result<(), String> { let start = std::time::Instant::now(); let timeout = Duration::from_secs(timeout_secs); let retry_interval = Duration::from_millis(retry_ms); while start.elapsed() < timeout { if check_backend_health(port, health_timeout_ms) .await .unwrap_or(false) { return Ok(()); } tokio::time::sleep(retry_interval).await; } Err("Backend did not start in time".to_string()) } pub async fn verify_backend_mode(port: u16, expected_mode: &str) -> Result<(), String> { let url = format!("http://127.0.0.1:{}/health", port); let client = Client::builder() .timeout(Duration::from_secs(5)) .build() .map_err(|e| e.to_string())?; match client.get(&url).send().await { Ok(response) => { if let Ok(health) = response.json::().await { if health.app.as_deref() != Some("lifetrace") { return Err(format!( "Backend at port {} is not a LifeTrace server", port )); } if let Some(mode) = health.server_mode { if mode != expected_mode { log::warn!( "Backend mode mismatch: expected '{}', got '{}'", expected_mode, mode ); } } } Ok(()) } Err(e) => { log::warn!("Could not verify backend mode: {}", e); Ok(()) } } } ================================================ FILE: free-todo-frontend/src-tauri/src/config.rs ================================================ //! Configuration constants for FreeTodo //! //! Centralized configuration management for ports, timeouts, and paths. //! //! ## Window Modes //! //! The application supports two window modes (matching Electron): //! - **Web**: Standard window (1200x800, with decorations) //! - **Island**: Transparent floating window (separate build config) use std::env; /// Server mode (development or production) #[derive(Debug, Clone, Copy, PartialEq)] pub enum ServerMode { Dev, Build, } impl ServerMode { /// Get current server mode based on build configuration pub fn current() -> Self { if let Ok(mode) = env::var("SERVER_MODE") { if mode.eq_ignore_ascii_case("dev") { return ServerMode::Dev; } if mode.eq_ignore_ascii_case("build") { return ServerMode::Build; } } if cfg!(debug_assertions) { ServerMode::Dev } else { ServerMode::Build } } } /// Port configuration based on server mode pub mod ports { use super::ServerMode; /// Dev mode ports pub const DEV_FRONTEND_PORT: u16 = 3001; pub const DEV_BACKEND_PORT: u16 = 8001; pub const DEV_BACKEND_RANGE_START: u16 = 8002; pub const DEV_BACKEND_RANGE_END: u16 = 8099; /// Build mode ports pub const BUILD_FRONTEND_PORT: u16 = 3100; pub const BUILD_BACKEND_PORT: u16 = 8100; pub const BUILD_BACKEND_RANGE_START: u16 = 8101; pub const BUILD_BACKEND_RANGE_END: u16 = 8199; /// Get frontend port for current mode pub fn frontend_port(mode: ServerMode) -> u16 { match mode { ServerMode::Dev => DEV_FRONTEND_PORT, ServerMode::Build => BUILD_FRONTEND_PORT, } } /// Get backend port for current mode pub fn backend_port(mode: ServerMode) -> u16 { match mode { ServerMode::Dev => DEV_BACKEND_PORT, ServerMode::Build => BUILD_BACKEND_PORT, } } } /// Timeout configuration (in milliseconds) pub mod timeouts { /// Backend ready timeout (3 minutes) pub const BACKEND_READY: u64 = 180_000; /// Frontend ready timeout (30 seconds) pub const FRONTEND_READY: u64 = 30_000; /// Health check timeout (5 seconds) pub const HEALTH_CHECK: u64 = 5_000; /// Health check retry interval (500ms) pub const HEALTH_CHECK_RETRY: u64 = 500; } /// Health check intervals (in milliseconds) pub mod health_check { /// Frontend health check interval (10 seconds) pub const FRONTEND_INTERVAL: u64 = 10_000; /// Backend health check interval (30 seconds) pub const BACKEND_INTERVAL: u64 = 30_000; } /// Process configuration pub mod process { /// Backend executable name (platform-specific) #[cfg(windows)] pub const BACKEND_EXEC_NAME: &str = "lifetrace.exe"; #[cfg(not(windows))] pub const BACKEND_EXEC_NAME: &str = "lifetrace"; /// Backend data directory name pub const BACKEND_DATA_DIR: &str = "lifetrace-data"; } /// Get the default backend port based on environment or mode pub fn get_backend_port() -> u16 { if let Ok(port) = env::var("BACKEND_PORT") { if let Ok(p) = port.parse() { return p; } } ports::backend_port(ServerMode::current()) } /// Get the default frontend port based on environment or mode pub fn get_frontend_port() -> u16 { if let Ok(port) = env::var("PORT") { if let Ok(p) = port.parse() { return p; } } ports::frontend_port(ServerMode::current()) } /// Get backend URL pub fn get_backend_url() -> String { format!("http://127.0.0.1:{}", get_backend_port()) } /// Get frontend URL pub fn get_frontend_url() -> String { format!("http://localhost:{}", get_frontend_port()) } ================================================ FILE: free-todo-frontend/src-tauri/src/lib.rs ================================================ //! FreeTodo - Tauri Application Library //! //! This module contains the core functionality for the FreeTodo desktop application, //! including backend management, Next.js server management, system tray, and global shortcuts. //! //! ## Window Modes //! //! The application supports two window modes (matching Electron implementation): //! - **Web Mode**: Standard window with decorations //! - **Island Mode**: Transparent floating window like Dynamic Island (separate build config) pub mod backend; mod backend_log; mod backend_paths; mod backend_proxy; mod backend_python; mod backend_support; pub mod config; pub mod nextjs; pub mod shortcut; pub mod tray; use log::info; use tauri::Manager; /// Window mode configuration /// Currently only Web mode is supported #[derive(Debug, Clone, Copy, PartialEq, Default)] #[allow(dead_code)] pub enum WindowMode { /// Standard window with decorations (default, currently supported) #[default] Web, /// Transparent floating window like Dynamic Island (TODO: not yet implemented) Island, } /// Initialize the Tauri application with all required plugins and setup /// Note: Currently only Web mode is supported pub fn run() { // Initialize logger env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); info!("Starting FreeTodo application..."); tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .setup(|app| { let handle = app.handle().clone(); info!("Application setup starting..."); // Start Python backend let backend_handle = handle.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = backend::start_backend(&backend_handle).await { log::error!("Failed to start backend: {}", e); } }); // Start Next.js server (only in release mode) #[cfg(not(debug_assertions))] { let nextjs_handle = handle.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = nextjs::start_nextjs(&nextjs_handle).await { log::error!("Failed to start Next.js: {}", e); } }); } // Setup system tray tray::setup_tray(app)?; // Setup global shortcuts shortcut::setup_shortcuts(app)?; info!("Application setup completed"); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_backend_url, get_backend_status, toggle_window, show_window, hide_window, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } /// Get the backend server URL #[tauri::command] fn get_backend_url() -> String { backend::get_backend_url() } /// Get backend server health status #[tauri::command] async fn get_backend_status() -> Result { backend::check_backend_health(config::get_backend_port()) .await .map_err(|e| e.to_string()) } /// Toggle main window visibility #[tauri::command] fn toggle_window(app: tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); } else { let _ = window.show(); let _ = window.set_focus(); } } } /// Show main window #[tauri::command] fn show_window(app: tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } /// Hide main window #[tauri::command] fn hide_window(app: tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); } } ================================================ FILE: free-todo-frontend/src-tauri/src/main.rs ================================================ //! FreeTodo - Main Entry Point //! //! This is the main entry point for the FreeTodo Tauri application. //! It initializes the application and starts all required services. #![cfg_attr( all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] fn main() { free_todo::run(); } ================================================ FILE: free-todo-frontend/src-tauri/src/nextjs.rs ================================================ //! Next.js Server Management //! //! This module handles the lifecycle of the Next.js standalone server, //! including starting, health checking, and stopping the process. use crate::backend; use crate::config::{self, timeouts}; use log::{error, info, warn}; use reqwest::Client; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use std::sync::Mutex; use std::time::Duration; use tauri::{AppHandle, Manager}; /// Global Next.js process reference static NEXTJS_PROCESS: Mutex> = Mutex::new(None); /// Current frontend port static FRONTEND_PORT: AtomicU16 = AtomicU16::new(3001); /// Flag indicating if server is stopping static IS_STOPPING: AtomicBool = AtomicBool::new(false); /// Get the frontend URL pub fn get_frontend_url() -> String { let port = FRONTEND_PORT.load(Ordering::Relaxed); format!("http://localhost:{}", port) } /// Set the frontend port pub fn set_frontend_port(port: u16) { FRONTEND_PORT.store(port, Ordering::Relaxed); } /// Get current frontend port pub fn get_frontend_port() -> u16 { FRONTEND_PORT.load(Ordering::Relaxed) } /// Check if server is healthy async fn check_server_health(port: u16) -> bool { let url = format!("http://localhost:{}", port); let client = Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap_or_default(); match client.get(&url).send().await { Ok(response) => { let status = response.status().as_u16(); status == 200 || status == 304 } Err(_) => false, } } /// Wait for server to be ready async fn wait_for_server(url: &str, timeout_ms: u64) -> Result<(), String> { let start = std::time::Instant::now(); let timeout = Duration::from_millis(timeout_ms); let retry_interval = Duration::from_millis(500); let client = Client::builder() .timeout(Duration::from_secs(5)) .build() .map_err(|e| e.to_string())?; while start.elapsed() < timeout { if let Ok(response) = client.get(url).send().await { let status = response.status().as_u16(); if status == 200 || status == 304 { return Ok(()); } } tokio::time::sleep(retry_interval).await; } Err(format!("Server did not start within {}ms", timeout_ms)) } /// Find available port starting from default async fn find_available_port(start_port: u16, max_attempts: u16) -> Result { for i in 0..max_attempts { let port = start_port + i; if !check_server_health(port).await { // Port is likely available (not responding) return Ok(port); } } Err(format!( "Could not find available port after {} attempts", max_attempts )) } /// Get standalone server path fn get_server_path(app: &AppHandle) -> Result { let resource_path = app .path() .resource_dir() .map_err(|e| format!("Failed to get resource dir: {}", e))?; let server_path = resource_path.join("standalone").join("server.js"); if server_path.exists() { Ok(server_path) } else { Err(format!("Server file not found at {:?}", server_path)) } } /// Start the Next.js server pub async fn start_nextjs(app: &AppHandle) -> Result<(), Box> { // In development mode, expect external dev server if cfg!(debug_assertions) { let port = config::get_frontend_port(); set_frontend_port(port); info!( "Development mode: expecting Next.js dev server at http://localhost:{}", port ); // Check if dev server is already running if check_server_health(port).await { info!("Next.js dev server is already running"); return Ok(()); } // Wait for external dev server info!("Waiting for Next.js dev server..."); match wait_for_server(&format!("http://localhost:{}", port), 30000).await { Ok(_) => { info!("Next.js dev server is ready"); return Ok(()); } Err(e) => { warn!("Dev server not available: {}", e); return Err(e.into()); } } } // Production mode: start standalone server info!("Starting Next.js production server..."); // Get server path let server_path = get_server_path(app)?; let server_dir = server_path .parent() .ok_or("Failed to get server directory")?; info!("Server path: {:?}", server_path); info!("Server directory: {:?}", server_dir); // Find available port let port = find_available_port(config::get_frontend_port(), 50).await?; set_frontend_port(port); info!("Frontend will use port: {}", port); // Get backend URL for environment variable let backend_url = backend::get_backend_url(); // Check for Node.js let node_path = which_node()?; info!("Node.js path: {:?}", node_path); // Spawn Next.js server process let child = Command::new(&node_path) .arg(&server_path) .current_dir(server_dir) .env("PORT", port.to_string()) .env("HOSTNAME", "localhost") .env("NODE_ENV", "production") .env("NEXT_PUBLIC_API_URL", &backend_url) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("Failed to start Next.js server: {}", e))?; info!("Spawned Next.js process with PID: {:?}", child.id()); // Store process reference { let mut guard = NEXTJS_PROCESS.lock().unwrap(); *guard = Some(child); } // Wait for server to be ready let server_url = format!("http://localhost:{}", port); info!( "Waiting for Next.js server at {} to be ready...", server_url ); wait_for_server(&server_url, timeouts::FRONTEND_READY).await?; info!("Next.js server is ready at {}", server_url); // Start health check loop start_health_check_loop(port); Ok(()) } /// Find Node.js executable fn which_node() -> Result { // Try common Node.js locations let candidates = if cfg!(windows) { vec![ "node.exe", "C:\\Program Files\\nodejs\\node.exe", "C:\\Program Files (x86)\\nodejs\\node.exe", ] } else { vec![ "node", "/usr/local/bin/node", "/usr/bin/node", "/opt/homebrew/bin/node", ] }; for candidate in candidates { let path = PathBuf::from(candidate); if path.exists() { return Ok(path); } // Try to find in PATH if let Ok(output) = Command::new(if cfg!(windows) { "where" } else { "which" }) .arg(candidate) .output() { if output.status.success() { let path_str = String::from_utf8_lossy(&output.stdout) .trim() .lines() .next() .unwrap_or("") .to_string(); if !path_str.is_empty() { return Ok(PathBuf::from(path_str)); } } } } Err("Node.js not found. Please install Node.js.".to_string()) } /// Start health check loop fn start_health_check_loop(port: u16) { tokio::spawn(async move { let interval = Duration::from_millis(config::health_check::FRONTEND_INTERVAL); loop { tokio::time::sleep(interval).await; if IS_STOPPING.load(Ordering::Relaxed) { break; } if !check_server_health(port).await { warn!("Next.js health check failed"); } } }); } /// Stop the Next.js server pub fn stop_nextjs() { IS_STOPPING.store(true, Ordering::Relaxed); let mut guard = NEXTJS_PROCESS.lock().unwrap(); if let Some(mut child) = guard.take() { info!("Stopping Next.js server..."); // Try graceful shutdown first #[cfg(unix)] { unsafe { libc::kill(child.id() as i32, libc::SIGTERM); } } #[cfg(windows)] { let _ = child.kill(); } // Wait a bit for graceful shutdown std::thread::sleep(Duration::from_secs(2)); // Force kill if still running match child.try_wait() { Ok(Some(_)) => { info!("Next.js server stopped gracefully"); } Ok(None) => { warn!("Next.js server did not stop gracefully, forcing kill"); let _ = child.kill(); } Err(e) => { error!("Error checking Next.js status: {}", e); let _ = child.kill(); } } } } /// Cleanup on application exit pub fn cleanup() { stop_nextjs(); } ================================================ FILE: free-todo-frontend/src-tauri/src/shortcut.rs ================================================ //! Global Shortcut Management //! //! This module handles global keyboard shortcuts for the application, //! providing quick access to common functions from anywhere in the system. //! //! Note: Currently designed for Web mode. Island mode may require different shortcuts. use log::{error, info, warn}; use tauri::{App, AppHandle, Manager}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; /// Default shortcut configurations pub struct ShortcutConfig { /// Toggle window visibility shortcut pub toggle_window: &'static str, } impl Default for ShortcutConfig { fn default() -> Self { Self { toggle_window: "CommandOrControl+Shift+I", } } } /// Setup global shortcuts pub fn setup_shortcuts(app: &App) -> Result<(), Box> { info!("Setting up global shortcuts..."); let config = ShortcutConfig::default(); let handle = app.handle().clone(); // Register toggle window shortcut register_toggle_shortcut(&handle, config.toggle_window)?; info!("Global shortcuts registered successfully"); Ok(()) } /// Register the toggle window shortcut fn register_toggle_shortcut( app: &AppHandle, accelerator: &str, ) -> Result<(), Box> { let shortcut: Shortcut = accelerator.parse()?; let app_handle = app.clone(); let accel_string = accelerator.to_string(); app.global_shortcut() .on_shortcut(shortcut, move |_app, _shortcut, event| { if event.state == ShortcutState::Pressed { info!("Toggle shortcut triggered: {}", accel_string); toggle_window(&app_handle); } })?; info!("Registered shortcut: {} - Toggle Window", accelerator); Ok(()) } /// Toggle main window visibility fn toggle_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { match window.is_visible() { Ok(true) => { if let Err(e) = window.hide() { error!("Failed to hide window: {}", e); } else { info!("Window hidden via shortcut"); } } Ok(false) => { if let Err(e) = window.show() { error!("Failed to show window: {}", e); } else if let Err(e) = window.set_focus() { warn!("Failed to focus window: {}", e); } else { info!("Window shown via shortcut"); } } Err(e) => { error!("Failed to check window visibility: {}", e); } } } else { error!("Main window not found"); } } /// Unregister all shortcuts #[allow(dead_code)] pub fn unregister_all(app: &AppHandle) { if let Err(e) = app.global_shortcut().unregister_all() { error!("Failed to unregister shortcuts: {}", e); } else { info!("All shortcuts unregistered"); } } /// Update a shortcut with a new accelerator #[allow(dead_code)] pub fn update_shortcut( app: &AppHandle, old_accelerator: &str, new_accelerator: &str, ) -> Result<(), Box> { // Unregister old shortcut let old_shortcut: Shortcut = old_accelerator.parse()?; app.global_shortcut().unregister(old_shortcut)?; // Register new shortcut register_toggle_shortcut(app, new_accelerator)?; info!( "Shortcut updated from {} to {}", old_accelerator, new_accelerator ); Ok(()) } /// Check if a shortcut is registered #[allow(dead_code)] pub fn is_registered(app: &AppHandle, accelerator: &str) -> bool { match accelerator.parse::() { Ok(shortcut) => app.global_shortcut().is_registered(shortcut), Err(_) => false, } } ================================================ FILE: free-todo-frontend/src-tauri/src/tray.rs ================================================ //! System Tray Management //! //! This module handles the system tray icon and context menu, //! providing quick access to common application functions. //! //! Note: Currently designed for Web mode. Island mode features are placeholders. use log::{error, info}; use tauri::{ image::Image, menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, App, AppHandle, Manager, }; /// Setup the system tray pub fn setup_tray(app: &App) -> Result<(), Box> { info!("Setting up system tray..."); let handle = app.handle(); // Create menu items let show_hide = MenuItem::with_id( handle, "show_hide", "Show/Hide Window", true, Some("CmdOrCtrl+Shift+I"), )?; let separator1 = PredefinedMenuItem::separator(handle)?; let recording_menu = create_recording_submenu(handle)?; let screenshot_menu = create_screenshot_submenu(handle)?; let separator2 = PredefinedMenuItem::separator(handle)?; let preferences = MenuItem::with_id(handle, "preferences", "Preferences...", true, None::<&str>)?; let separator3 = PredefinedMenuItem::separator(handle)?; let quit = MenuItem::with_id(handle, "quit", "Quit FreeTodo", true, Some("CmdOrCtrl+Q"))?; // Build the menu let menu = Menu::with_items( handle, &[ &show_hide, &separator1, &recording_menu, &screenshot_menu, &separator2, &preferences, &separator3, &quit, ], )?; // Get tray icon let icon = get_tray_icon(app)?; // Create tray icon let _tray = TrayIconBuilder::new() .icon(icon) .menu(&menu) .tooltip("FreeTodo - Dynamic Island") .on_menu_event(move |app, event| { handle_menu_event(app, event.id.as_ref()); }) .on_tray_icon_event(|tray, event| { handle_tray_event(tray.app_handle(), event); }) .build(app)?; info!("System tray created successfully"); Ok(()) } /// Create recording submenu fn create_recording_submenu( handle: &AppHandle, ) -> Result, tauri::Error> { let start_recording = MenuItem::with_id( handle, "start_recording", "Start Recording", false, None::<&str>, )?; let stop_recording = MenuItem::with_id( handle, "stop_recording", "Stop Recording", false, None::<&str>, )?; tauri::menu::Submenu::with_items( handle, "Recording", true, &[&start_recording, &stop_recording], ) } /// Create screenshot submenu fn create_screenshot_submenu( handle: &AppHandle, ) -> Result, tauri::Error> { let take_screenshot = MenuItem::with_id( handle, "take_screenshot", "Take Screenshot", false, None::<&str>, )?; let view_screenshots = MenuItem::with_id( handle, "view_screenshots", "View Recent...", false, None::<&str>, )?; tauri::menu::Submenu::with_items( handle, "Screenshots", true, &[&take_screenshot, &view_screenshots], ) } /// Get tray icon image fn get_tray_icon(_app: &App) -> Result, Box> { // Load embedded icon (using PNG decoder) let icon_bytes = include_bytes!("../icons/icon.png"); // Decode PNG to get RGBA data let decoder = png::Decoder::new(std::io::Cursor::new(icon_bytes)); let mut reader = decoder.read_info()?; let buf_size = reader.output_buffer_size().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidData, "PNG output buffer size overflow", ) })?; let mut buf = vec![0; buf_size]; let info = reader.next_frame(&mut buf)?; // Convert to RGBA if necessary let rgba = match info.color_type { png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(), png::ColorType::Rgb => { // Convert RGB to RGBA let rgb = &buf[..info.buffer_size()]; let mut rgba = Vec::with_capacity((rgb.len() / 3) * 4); for chunk in rgb.chunks(3) { rgba.extend_from_slice(chunk); rgba.push(255); } rgba } _ => { error!("Unsupported color type: {:?}", info.color_type); return Err("Unsupported color type".into()); } }; Ok(Image::new_owned(rgba, info.width, info.height)) } /// Handle menu item click events fn handle_menu_event(app: &AppHandle, menu_id: &str) { info!("Menu event: {}", menu_id); match menu_id { "show_hide" => { toggle_window(app); } "preferences" => { // Show preferences (for now, just show window) show_window(app); info!("Preferences clicked - feature not yet implemented"); } "quit" => { info!("Quit requested from tray menu"); app.exit(0); } "start_recording" => { info!("Start recording - feature not yet implemented"); } "stop_recording" => { info!("Stop recording - feature not yet implemented"); } "take_screenshot" => { info!("Take screenshot - feature not yet implemented"); } "view_screenshots" => { info!("View screenshots - feature not yet implemented"); } _ => { info!("Unknown menu event: {}", menu_id); } } } /// Handle tray icon events (click, double-click, etc.) fn handle_tray_event(app: &AppHandle, event: TrayIconEvent) { match event { TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } => { info!("Tray icon left-clicked"); toggle_window(app); } TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } => { info!("Tray icon double-clicked"); show_window(app); } _ => {} } } /// Toggle main window visibility fn toggle_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { match window.is_visible() { Ok(true) => { let _ = window.hide(); info!("Window hidden"); } Ok(false) => { let _ = window.show(); let _ = window.set_focus(); info!("Window shown"); } Err(e) => { error!("Failed to check window visibility: {}", e); } } } else { error!("Main window not found"); } } /// Show main window fn show_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); info!("Window shown and focused"); } } /// Hide main window #[allow(dead_code)] fn hide_window(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); info!("Window hidden"); } } /// Update tray tooltip based on window state #[allow(dead_code)] pub fn update_tray_tooltip(_app: &AppHandle, visible: bool) { // Tray tooltip update would be implemented here // Currently Tauri 2.x doesn't have a direct API for updating tooltip after creation info!( "Tray state updated: Window is {}", if visible { "visible" } else { "hidden" } ); } ================================================ FILE: free-todo-frontend/src-tauri/tauri.conf.json ================================================ { "$schema": "https://schema.tauri.app/config/2.1", "productName": "FreeTodo", "version": "0.1.2", "identifier": "com.freeugroup.freetodo", "build": { "beforeBuildCommand": "pnpm build:frontend:web && pnpm tauri:prebuild", "devUrl": "http://localhost:3001", "frontendDist": "dist" }, "app": { "withGlobalTauri": false, "windows": [ { "title": "FreeTodo", "width": 1200, "height": 800, "minWidth": 800, "minHeight": 600, "resizable": true, "fullscreen": false, "transparent": false, "decorations": true, "visible": true, "center": true } ], "security": { "csp": null, "assetProtocol": { "enable": true, "scope": ["**"] } }, "trayIcon": { "iconPath": "icons/icon.png", "iconAsTemplate": true } }, "bundle": { "active": true, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ], "targets": "all", "resources": [], "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "" }, "macOS": { "frameworks": [], "minimumSystemVersion": "", "exceptionDomain": "", "signingIdentity": null, "providerShortName": null, "entitlements": null }, "linux": { "deb": { "section": "utility" }, "appimage": { "bundleMediaFramework": false } } }, "plugins": { "shell": { "open": true } } } ================================================ FILE: free-todo-frontend/src-tauri/tauri.island.pyinstaller.json ================================================ { "$schema": "https://schema.tauri.app/config/2.1", "productName": "FreeTodo Island", "version": "0.1.2", "identifier": "com.freeugroup.freetodo.island", "build": { "beforeBuildCommand": "pnpm build:frontend:island && pnpm tauri:prebuild", "devUrl": "http://localhost:3001", "frontendDist": "dist" }, "app": { "withGlobalTauri": false, "windows": [ { "title": "FreeTodo Island", "width": 380, "height": 120, "minWidth": 300, "minHeight": 80, "resizable": false, "fullscreen": false, "transparent": true, "decorations": false, "alwaysOnTop": true, "skipTaskbar": true, "visible": true, "center": false } ], "security": { "csp": null, "assetProtocol": { "enable": true, "scope": ["**"] } }, "trayIcon": { "iconPath": "icons/icon.png", "iconAsTemplate": true } }, "bundle": { "active": true, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ], "targets": "all", "resources": [ "../../dist-backend", "../.next/standalone" ], "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "" }, "macOS": { "frameworks": [], "minimumSystemVersion": "", "exceptionDomain": "", "signingIdentity": null, "providerShortName": null, "entitlements": null }, "linux": { "deb": { "section": "utility" }, "appimage": { "bundleMediaFramework": false } } }, "plugins": { "shell": { "open": true } } } ================================================ FILE: free-todo-frontend/src-tauri/tauri.island.script.json ================================================ { "$schema": "https://schema.tauri.app/config/2.1", "productName": "FreeTodo Island", "version": "0.1.2", "identifier": "com.freeugroup.freetodo.island", "build": { "beforeBuildCommand": "pnpm build:frontend:island && pnpm tauri:prebuild", "devUrl": "http://localhost:3001", "frontendDist": "dist" }, "app": { "withGlobalTauri": false, "windows": [ { "title": "FreeTodo Island", "width": 380, "height": 120, "minWidth": 300, "minHeight": 80, "resizable": false, "fullscreen": false, "transparent": true, "decorations": false, "alwaysOnTop": true, "skipTaskbar": true, "visible": true, "center": false } ], "security": { "csp": null, "assetProtocol": { "enable": true, "scope": ["**"] } }, "trayIcon": { "iconPath": "icons/icon.png", "iconAsTemplate": true } }, "bundle": { "active": true, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ], "targets": "all", "resources": [ "../../lifetrace/__init__.py", "../../lifetrace/alembic.ini", "../../lifetrace/server.py", "../../lifetrace/config/default_config.yaml", "../../lifetrace/config/prompt.yaml", "../../lifetrace/config/rapidocr_config.yaml", "../../lifetrace/config/prompts", "../../lifetrace/core", "../../lifetrace/docs", "../../lifetrace/jobs", "../../lifetrace/llm", "../../lifetrace/migrations", "../../lifetrace/models", "../../lifetrace/observability", "../../lifetrace/repositories", "../../lifetrace/routers", "../../lifetrace/schemas", "../../lifetrace/scripts", "../../lifetrace/services", "../../lifetrace/storage", "../../lifetrace/util", "../../requirements-runtime.txt", "../.next/standalone" ], "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "" }, "macOS": { "frameworks": [], "minimumSystemVersion": "", "exceptionDomain": "", "signingIdentity": null, "providerShortName": null, "entitlements": null }, "linux": { "deb": { "section": "utility" }, "appimage": { "bundleMediaFramework": false } } }, "plugins": { "shell": { "open": true } } } ================================================ FILE: free-todo-frontend/src-tauri/tauri.lint.json ================================================ { "$schema": "https://schema.tauri.app/config/2.1", "productName": "FreeTodo", "version": "0.1.2", "identifier": "com.freeugroup.freetodo", "build": { "beforeBuildCommand": "", "devUrl": "http://localhost:3001", "frontendDist": ".tauri-lint-dist" }, "app": { "withGlobalTauri": false, "windows": [ { "title": "FreeTodo", "width": 1200, "height": 800, "minWidth": 800, "minHeight": 600, "resizable": true, "fullscreen": false, "transparent": false, "decorations": true, "visible": true, "center": true } ], "security": { "csp": null, "assetProtocol": { "enable": true, "scope": ["**"] } }, "trayIcon": { "iconPath": "icons/icon.png", "iconAsTemplate": true } }, "bundle": { "active": true, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ], "targets": "all", "resources": [], "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "" }, "macOS": { "frameworks": [], "minimumSystemVersion": "", "exceptionDomain": "", "signingIdentity": null, "providerShortName": null, "entitlements": null }, "linux": { "deb": { "section": "utility" }, "appimage": { "bundleMediaFramework": false } } }, "plugins": { "shell": { "open": true } } } ================================================ FILE: free-todo-frontend/src-tauri/tauri.web.pyinstaller.json ================================================ { "$schema": "https://schema.tauri.app/config/2.1", "productName": "FreeTodo", "version": "0.1.2", "identifier": "com.freeugroup.freetodo", "build": { "beforeBuildCommand": "pnpm build:frontend:web && pnpm tauri:prebuild", "devUrl": "http://localhost:3001", "frontendDist": "dist" }, "app": { "withGlobalTauri": false, "windows": [ { "title": "FreeTodo", "width": 1200, "height": 800, "minWidth": 800, "minHeight": 600, "resizable": true, "fullscreen": false, "transparent": false, "decorations": true, "visible": true, "center": true } ], "security": { "csp": null, "assetProtocol": { "enable": true, "scope": ["**"] } }, "trayIcon": { "iconPath": "icons/icon.png", "iconAsTemplate": true } }, "bundle": { "active": true, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ], "targets": "all", "resources": [ "../../dist-backend", "../.next/standalone" ], "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "" }, "macOS": { "frameworks": [], "minimumSystemVersion": "", "exceptionDomain": "", "signingIdentity": null, "providerShortName": null, "entitlements": null }, "linux": { "deb": { "section": "utility" }, "appimage": { "bundleMediaFramework": false } } }, "plugins": { "shell": { "open": true } } } ================================================ FILE: free-todo-frontend/src-tauri/tauri.web.script.json ================================================ { "$schema": "https://schema.tauri.app/config/2.1", "productName": "FreeTodo", "version": "0.1.2", "identifier": "com.freeugroup.freetodo", "build": { "beforeBuildCommand": "pnpm build:frontend:web && pnpm tauri:prebuild", "devUrl": "http://localhost:3001", "frontendDist": "dist" }, "app": { "withGlobalTauri": false, "windows": [ { "title": "FreeTodo", "width": 1200, "height": 800, "minWidth": 800, "minHeight": 600, "resizable": true, "fullscreen": false, "transparent": false, "decorations": true, "visible": true, "center": true } ], "security": { "csp": null, "assetProtocol": { "enable": true, "scope": ["**"] } }, "trayIcon": { "iconPath": "icons/icon.png", "iconAsTemplate": true } }, "bundle": { "active": true, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico" ], "targets": "all", "resources": [ "../../lifetrace/__init__.py", "../../lifetrace/alembic.ini", "../../lifetrace/server.py", "../../lifetrace/config/default_config.yaml", "../../lifetrace/config/prompt.yaml", "../../lifetrace/config/rapidocr_config.yaml", "../../lifetrace/config/prompts", "../../lifetrace/core", "../../lifetrace/docs", "../../lifetrace/jobs", "../../lifetrace/llm", "../../lifetrace/migrations", "../../lifetrace/models", "../../lifetrace/observability", "../../lifetrace/repositories", "../../lifetrace/routers", "../../lifetrace/schemas", "../../lifetrace/scripts", "../../lifetrace/services", "../../lifetrace/storage", "../../lifetrace/util", "../../requirements-runtime.txt", "../.next/standalone" ], "windows": { "certificateThumbprint": null, "digestAlgorithm": "sha256", "timestampUrl": "" }, "macOS": { "frameworks": [], "minimumSystemVersion": "", "exceptionDomain": "", "signingIdentity": null, "providerShortName": null, "entitlements": null }, "linux": { "deb": { "section": "utility" }, "appimage": { "bundleMediaFramework": false } } }, "plugins": { "shell": { "open": true } } } ================================================ FILE: free-todo-frontend/tailwind.config.ts ================================================ import type { Config } from "tailwindcss"; const config: Config = { darkMode: "class", content: [ "./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}", "./apps/**/*.{ts,tsx}", ], plugins: [require("@tailwindcss/typography")], }; export default config; ================================================ FILE: free-todo-frontend/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": false, "skipLibCheck": true, "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noUnusedLocals": true, "noUnusedParameters": true, "noEmit": true, "module": "ESNext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "incremental": true, "baseUrl": ".", "paths": { "@/*": ["./*"] }, "allowImportingTsExtensions": true, "moduleDetection": "force", "esModuleInterop": true, "plugins": [ { "name": "next" } ] }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules", "dist-electron", "dist-electron-app", "dist", "dist-backend", "../dist-backend", "src-tauri" ] } ================================================ FILE: lifetrace/__init__.py ================================================ """ LifeTrace - A cross-platform screen recording and activity tracking application """ __version__ = "0.1.0" ================================================ FILE: lifetrace/alembic.ini ================================================ # Alembic Configuration File [alembic] script_location = migrations prepend_sys_path = . sqlalchemy.url = sqlite:///data/lifetrace.db # 日志配置 [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: lifetrace/config/default_config.yaml ================================================ # LifeTrace 默认配置文件 # !!!重要提示: # 请勿编辑此文件 default_config.yaml,所有配置都应在 config.yaml 中进行设置 # 如果 config.yaml 不存在,系统会自动从 default_config.yaml 复制并生成 # 如果 config.yaml 存在,系统会自动验证完整性,如果不完整,系统会自动提示并退出 # 当此提示出现在 config.yaml 中时,请忽略 # 服务器配置 server: host: 127.0.0.1 port: 8001 # 默认端口 8001,保留 8000 给其他服务 debug: false # 后端模块启用配置(轻量插件化) backend_modules: enabled: - health - config - system - logs - chat - activity - search - screenshot - event - ocr - vector - rag - scheduler - cost_tracking - time_allocation - todo - todo_extraction - journal - vision - notification - floating_capture - audio - proactive_ocr # 基础目录配置 base_dir: data database_path: lifetrace.db screenshots_dir: screenshots/ attachments_dir: attachments/ # 日志配置 logging: level: INFO console_level: INFO file_level: INFO quiet_modules: [] # 示例: ["activity_service", "event_service"] log_path: logs/ # 调度器配置 scheduler: enabled: true # 启用调度器 database_path: scheduler.db # 调度器数据库路径 max_workers: 10 # 最大工作线程数 coalesce: true # 合并错过的任务 max_instances: 1 # 同一任务同时只能有一个实例 misfire_grace_time: 60 # 错过触发时间的容忍度(秒) timezone: Asia/Shanghai # 时区 # 定时任务 jobs: recorder: id: recorder # 任务ID name: '屏幕录制' # 任务显示名称(中文) enabled: false # 是否启用录制器任务(默认关闭,需用户手动开启) interval: 10 # 截图间隔(秒) params: screens: all # 截图屏幕:all 或屏幕编号列表 auto_exclude_self: true # 自动排除 LifeTrace 自身窗口 deduplicate: true # 启用截图去重(通过文件哈希避免保存重复截图) hash_threshold: 5 # 图像哈希去重阈值(汉明距离),值越小越严格 file_io_timeout: 15 # 文件I/O操作超时时间(秒) db_timeout: 20 # 数据库操作超时时间(秒) window_info_timeout: 5 # 获取窗口信息超时时间(秒) blacklist: enabled: false # 是否启用黑名单功能 apps: ['微信'] # 应用黑名单,使用友好名称,例如: ["微信", "QQ", "钉钉"] windows: [] # 窗口标题黑名单,例如: ["记事本", "计算器"] auto_todo_detection: id: auto_todo_detection # 任务ID name: 自动待办检测 # 任务显示名称(中文) enabled: false # 是否启用自动待办检测(默认关闭,需用户手动开启) params: whitelist: apps: ['微信', 'WeChat', '飞书', 'Feishu', 'Lark', '钉钉', 'DingTalk'] # 应用白名单,只有这些应用的截图才会触发自动待办检测 todo_recorder: id: todo_recorder # 任务ID name: '屏幕录制(Todo生成)' # 任务显示名称(中文) enabled: false # 是否启用 Todo 专用录制(默认关闭,与 auto_todo_detection 联动) interval: 5 # 截图间隔(秒),默认5秒 params: # 白名单应用从 auto_todo_detection.params.whitelist.apps 读取,无需重复配置 deduplicate: true # 启用截图去重 hash_threshold: 5 # 图像哈希去重阈值(汉明距离) file_io_timeout: 15 # 文件I/O操作超时时间(秒) db_timeout: 20 # 数据库操作超时时间(秒) window_info_timeout: 5 # 获取窗口信息超时时间(秒) ocr: id: ocr # 任务ID name: OCR识别 # 任务显示名称(中文) enabled: false # 是否启用OCR任务(默认关闭,需用户手动开启) interval: 10 # 数据库检查间隔(秒) params: use_gpu: false language: ['ch', 'en'] confidence_threshold: 0.5 audio_recording: id: audio_recording # 任务ID name: 音频录制 # 任务显示名称(中文) enabled: false # 是否启用音频录制(默认关闭,7x24小时录制) interval: 60 # 状态检查间隔(秒),用于监控录音状态 params: segment_duration_minutes: 30 # 分段时长(分钟) silence_threshold_seconds: 600 # 静音检测阈值(秒) activity_aggregator: id: activity_aggregator # 任务ID name: 活动聚合 # 任务显示名称(中文) enabled: false # 是否启用活动聚合任务(默认关闭,需用户手动开启) interval: 900 # 检查间隔(秒),默认15分钟 clean_data: id: clean_data # 任务ID name: 截图清理 # 任务显示名称(中文) enabled: false # 是否启用截图清理任务(默认关闭,需要时手动开启) interval: 3600 # 检查间隔(秒),默认每小时检查一次 params: max_screenshots: 10000 # 最大截图数量限制 max_days: 30 # 数据保留天数(按日期清理旧数据) delete_file_only: true # 只删除文件(true),还是同时删除记录(false) deadline_reminder: id: deadline_reminder # 任务ID name: DDL提醒 # 任务显示名称(中文) enabled: false # 是否启用 DDL 提醒任务(默认关闭,需用户手动开启) params: reminder_window_minutes: 5 # 兼容保留字段,不再作为待办默认提醒 proactive_ocr: id: proactive_ocr # 任务ID name: 主动OCR # 任务显示名称(中文) enabled: false # 是否启用主动OCR任务(默认关闭,需用户手动开启,仅Windows) interval: 1.0 # 检测间隔(秒),默认1秒 params: apps: ["wechat", "feishu"] # 目标应用列表 use_roi: true # 是否使用ROI裁切加速(只识别聊天区域) resize_max_side: 800 # 图像预缩放最大边长,0表示不缩放 det_limit_side_len: 640 # OCR检测输入边长限制 min_confidence: 0.8 # 最低置信度阈值 auto_extract_todos: true # 是否在识别后自动触发基于 OCR 文本的待办提取 min_text_length: 5 # 触发自动待办提取所需的最小文本长度(按字符数) # 向量数据库配置 vector_db: enabled: true # 启用向量数据库 collection_name: lifetrace_ocr # 集合名称 embedding_model: shibing624/text2vec-base-chinese # 嵌入模型 rerank_model: BAAI/bge-reranker-base # 重排序模型 persist_directory: vector_db # 持久化目录 # 聊天配置 chat: enable_history: true # 开启后发送消息时附带历史上下文 history_limit: 10 # 历史记录轮数限制(1轮=1个用户消息+1个助手回复) # LLM配置 llm: api_key: YOUR_LLM_KEY_HERE # LLM 密钥,需要用户配置 base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # LLM API基础URL model: qwen-plus # 使用的模型名称 vision_model: qwen3-vl-plus # 视觉多模态模型名称(用于图片分析) temperature: 0.7 # 温度参数 max_tokens: 2048 # 最大token数 # 模型价格配置(单位:人民币/千token) model_prices: default: # 默认价格,用于未配置的模型(qwen-plus) input_price: 0.0008 # 输入token价格 output_price: 0.002 # 输出token价格 qwen3-max: input_price: 0.0032 output_price: 0.0128 qwen-plus: input_price: 0.0008 output_price: 0.002 qwen-turbo: input_price: 0.0003 output_price: 0.0006 qwen3-vl-plus: # 视觉模型价格(支持非思考/思考模式,按输入token分层计价) # 兼容旧逻辑的基础单价(默认取第一档) input_price: 0.001 # 输入token价格(元/千token) output_price: 0.01 # 输出token价格(元/千token) tiers: # (0, 32,000] - max_input_tokens: 32000 input_price: 0.001 output_price: 0.01 # (32,000, 128,000] - max_input_tokens: 128000 input_price: 0.0015 output_price: 0.015 # (128,000, 256,000] - max_input_tokens: 256000 input_price: 0.003 output_price: 0.03 # Tavily 配置(联网搜索) tavily: api_key: YOUR_TAVILY_API_KEY_HERE # Tavily API Key,需要用户配置 search_depth: basic # 搜索深度:basic 或 advanced max_results: 5 # 最大返回结果数 include_domains: [] # 包含的域名列表(可选) exclude_domains: [] # 排除的域名列表(可选) # 音频识别配置(阿里云Fun-ASR) audio: is_24x7: false # 7x24小时录制模式(自动启动录音,默认关闭) asr: api_key: YOUR_LLM_KEY_HERE # 阿里云百炼API Key base_url: wss://dashscope.aliyuncs.com/api-ws/v1/inference/ # WebSocket地址 model: fun-asr-realtime # 使用的模型名称 sample_rate: 16000 # 采样率(Hz) format: pcm # 音频格式:pcm, wav, mp3, opus, speex, aac, amr semantic_punctuation_enabled: false # 是否开启语义断句 max_sentence_silence: 1300 # VAD静音时长阈值(毫秒) heartbeat: false # 是否开启长连接保持 storage: audio_dir: audio/ # 音频文件存储目录 temp_audio_dir: temp_audio/ # 临时音频文件目录 # 可观测性配置(Phoenix + OpenInference) observability: enabled: true # 是否启用观测功能(默认关闭,需用户手动开启) mode: both # 导出模式:local(本地JSON文件)| phoenix(Phoenix UI)| both(两者都启用) local: traces_dir: traces/ # trace 文件存储目录(相对于 base_dir) max_files: 100 # 最大保留文件数(超出后自动清理旧文件) pretty_print: true # JSON 是否格式化输出(便于人类阅读) phoenix: endpoint: http://localhost:6006 # Phoenix 服务端点(需先启动 phoenix serve) project_name: freetodo-agent # 项目名称(用于 Phoenix UI 中分组) export_timeout_sec: 2.0 # 导出超时(秒),避免 Phoenix 不可用时阻塞 disable_after_failures: 1 # 连续失败达到阈值后自动禁用 Phoenix 导出 retry_cooldown_sec: 60 # 触发禁用后多久尝试恢复(秒,0 表示不自动重试) terminal: summary_only: true # Terminal 是否只输出一行摘要(推荐 true,保持日志精简) ================================================ FILE: lifetrace/config/prompt.yaml ================================================ # ============================================================ # LifeTrace Prompt 配置文件 - 已迁移说明 # ============================================================ # # 提示词配置已拆分到 prompts/ 目录下的多个文件中, # 便于维护和管理。 # # 新的文件结构: # config/prompts/ # ├── rag.yaml # RAG 服务相关提示词 # ├── llm.yaml # LLM 客户端相关提示词 # ├── summary.yaml # 摘要服务(event_summary, activity_summary, context_builder) # ├── todo.yaml # 待办相关(todo_extraction, auto_todo_detection) # ├── plan.yaml # Plan 功能(plan_questionnaire, plan_summary) # ├── chat.yaml # 前端聊天(chat_frontend) # └── search.yaml # 搜索相关(web_search, agent) # # PromptLoader 会自动从 prompts/ 目录加载所有 yaml 文件。 # 如果 prompts/ 目录不存在,则回退到本文件(兼容旧版本)。 # # 使用方式保持不变: # from lifetrace.util.prompt_loader import get_prompt # prompt = get_prompt("rag", "system_help") # # ============================================================ # 此文件现在为空,所有提示词已迁移到 prompts/ 目录 # 如需恢复单文件模式,可删除 prompts/ 目录并在此文件中添加提示词配置 ================================================ FILE: lifetrace/config/prompts/agno_tools/en/breakdown.yaml ================================================ # Task Breakdown Tool Messages - English # Task breakdown guide (for Agent to break down directly, avoiding nested LLM calls) breakdown_guide: | Please break down the following task into specific, actionable subtasks. **Task Description:** {task_description} **Breakdown Requirements:** 1. Each subtask should be specific and actionable 2. Subtasks should have a logical execution order 3. Estimate time for each subtask (optional) 4. Display in a clear list format with name, description, and estimated time for each subtask **Output Format Example:** 1. Subtask Name Detailed description [Estimated time] 2. Subtask Name Detailed description [Estimated time] Please break down the task directly and display the results without calling other tools. # Keep old message keys for compatibility (deprecated but kept for reference) breakdown_prompt: | Please break down the following task into specific, actionable subtasks. **Task Description:** {task_description} **Requirements:** 1. Each subtask should be specific and actionable 2. Subtasks should have a logical execution order 3. Estimate time for each subtask (optional) 4. Return in JSON format **Return Format:** ```json {{ "subtasks": [ {{"name": "subtask name", "description": "details", "estimated_time": "estimated time"}}, ... ] }} ``` breakdown_result: "Task breakdown result:\n{result}" breakdown_failed: "Failed to break down task: {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/en/conflict.yaml ================================================ # Conflict Detection Tool Messages - English conflict_found: "Found {count} conflict(s) in {time_range}:\n{conflicts}" conflict_item: "- #{id} {name} ({start} - {end})" no_conflict: "No conflicts in {time_range}, available for scheduling" conflict_failed: "Conflict check failed: {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/en/instructions.yaml ================================================ # Agent System Instructions - English instructions: | You are FreeTodo AI assistant, helping users manage their todos. **Tool Usage Guide:** 1. Use Todo management tools when users ask to create, query, update, or delete todos 2. For time-related inputs, use parse_time tool first to convert to ISO format 3. When users ask about schedule, use check_schedule_conflict to detect conflicts 4. For statistics or analysis, use get_todo_stats or get_overdue_todos 5. **Task Breakdown**: When users ask to break down complex tasks, call breakdown_task tool to get breakdown guidance, then **directly** break down the task into subtasks and display to users without calling LLM again 6. **Tag Suggestion**: When users need tag suggestions, call suggest_tags tool to get existing tags and suggestion guidance, then **directly** suggest appropriate tags without calling LLM again **Performance Optimization:** - breakdown_task and suggest_tags tools return breakdown/suggestion guidance - You should directly complete task breakdown or tag suggestion based on the guidance, without calling LLM again - This avoids nested LLM calls and significantly improves response speed **Notes:** - After successful operations, briefly inform users of the result - If operation fails, explain the reason and provide suggestions - When listing todos, sort by priority and scheduled time - When breaking down tasks, ensure subtasks are specific, actionable, and have a logical execution order ================================================ FILE: lifetrace/config/prompts/agno_tools/en/stats.yaml ================================================ # Statistics Tool Messages - English # Stats stats_header: "Todo Statistics ({date_range}):\n" stats_total: "- Total: {total}" stats_completed: "- Completed: {completed}" stats_active: "- Active: {active}" stats_overdue: "- Overdue: {overdue}" stats_by_priority: "- By priority: High({high}) Medium({medium}) Low({low}) None({none})" stats_failed: "Failed to get statistics: {error}" # Overdue overdue_header: "Overdue todos ({count} items):\n" overdue_item: "- #{id} {name} (overdue by {days} days)" no_overdue: "No overdue todos" ================================================ FILE: lifetrace/config/prompts/agno_tools/en/tags.yaml ================================================ # Tag Management Tool Messages - English # Tag list tags_header: "All tags ({count} total):\n" tags_item: "- {tag} ({count} todos)" tags_empty: "No tags found" # Todos by tag todos_by_tag_header: "Todos with tag \"{tag}\" ({count} items):\n" todos_by_tag_item: "- #{id} [{status}] {name}" todos_by_tag_empty: "No todos found with tag \"{tag}\"" # Tag suggestion guide (for Agent to suggest directly, avoiding nested LLM calls) suggest_tags_guide: | Please suggest 3-5 appropriate tags based on the following todo name. **Todo name:** {todo_name} **Existing tags (for reference, you can reuse or create new ones):** {existing_tags} **Suggestion Requirements:** 1. Suggest 3-5 relevant tags 2. Prefer reusing existing tags when appropriate 3. Create new tags if existing ones don't fit 4. Tags should be concise, meaningful, and useful for categorization Please suggest tags directly and display the results without calling other tools. Format: Suggested tags: tag1, tag2, tag3 # Keep old message keys for compatibility (deprecated but kept for reference) suggest_tags_prompt: | Suggest 3-5 appropriate tags based on the following todo name. **Todo name:** {todo_name} **Existing tags (for reference):** {existing_tags} Return in JSON format: ```json {{"suggested_tags": ["tag1", "tag2", "tag3"]}} ``` suggest_tags_result: "Suggested tags: {tags}" suggest_tags_failed: "Failed to suggest tags: {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/en/time.yaml ================================================ # Time Parsing Tool Messages - English parse_time_success: "Parsed result: {result}" parse_time_failed: "Cannot parse time expression \"{expression}\": {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/en/todo.yaml ================================================ # Todo Management Tool Messages - English # Create create_success: "Successfully created todo #{id}: {name}" create_failed: "Failed to create todo: {error}" # Complete complete_success: "Todo #{id} marked as completed" complete_not_found: "Todo #{id} not found" complete_failed: "Failed to complete todo: {error}" # Update update_success: "Todo #{id} updated" update_not_found: "Todo #{id} not found" update_failed: "Failed to update todo: {error}" # List list_header: "Todo list ({status}, {count} items):\n" list_item: "- #{id} [{priority}] {name}" list_item_with_time: " (time: {time})" list_empty: "No {status} todos found" # Search search_header: "Search results for \"{keyword}\" ({count} items):\n" search_item: "- #{id} [{status}] {name}" search_empty: "No todos found containing \"{keyword}\"" # Delete delete_success: "Todo #{id} deleted" delete_not_found: "Todo #{id} not found" delete_failed: "Failed to delete todo: {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/zh/breakdown.yaml ================================================ # 任务拆解工具消息 - 中文版 # 任务拆解指导(用于 Agent 直接拆解,避免嵌套 LLM 调用) breakdown_guide: | 请将以下任务拆解为具体的、可执行的子任务列表。 **任务描述:** {task_description} **拆解要求:** 1. 每个子任务应该是具体的、可执行的 2. 子任务之间应该有合理的执行顺序 3. 估算每个子任务的大致时间(可选) 4. 以清晰的列表格式展示,每个子任务包含名称、描述和预计时间 **输出格式示例:** 1. 子任务名称 详细描述 [预计时间] 2. 子任务名称 详细描述 [预计时间] 请直接拆解任务并展示结果,无需调用其他工具。 # 保留旧的消息键以兼容性(已废弃,但保留以防引用) breakdown_prompt: | 请将以下任务拆解为具体的、可执行的子任务列表。 **任务描述:** {task_description} **要求:** 1. 每个子任务应该是具体的、可执行的 2. 子任务之间应该有合理的执行顺序 3. 估算每个子任务的大致时间(可选) 4. 返回 JSON 格式 **返回格式:** ```json {{ "subtasks": [ {{"name": "子任务名称", "description": "详细描述", "estimated_time": "预计时间"}}, ... ] }} ``` breakdown_result: "任务拆解结果:\n{result}" breakdown_failed: "任务拆解失败: {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/zh/conflict.yaml ================================================ # 冲突检测工具消息 - 中文版 conflict_found: "在 {time_range} 时间段内发现 {count} 个冲突:\n{conflicts}" conflict_item: "- #{id} {name} ({start} - {end})" no_conflict: "在 {time_range} 时间段内没有冲突,可以安排" conflict_failed: "冲突检测失败: {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/zh/instructions.yaml ================================================ # Agent 系统指令 - 中文版 instructions: | 你是 FreeTodo 智能助手,可以帮助用户管理待办事项。 **工具使用指南:** 1. 当用户要求创建、查询、更新、删除待办时,使用相应的 Todo 管理工具 2. 时间相关的输入请先用 parse_time 工具解析为 ISO 格式 3. 当用户询问日程安排时,使用 check_schedule_conflict 检测时间冲突 4. 当用户想要统计或分析待办时,使用 get_todo_stats 或 get_overdue_todos 5. **任务拆解**:当用户要求拆解复杂任务时,调用 breakdown_task 工具获取拆解指导,然后**直接**将任务拆解为子任务列表展示给用户,无需再次调用 LLM 6. **标签推荐**:当用户需要推荐标签时,调用 suggest_tags 工具获取现有标签和推荐指导,然后**直接**推荐合适的标签,无需再次调用 LLM **性能优化说明:** - breakdown_task 和 suggest_tags 工具会返回拆解/推荐指导信息 - 你应该直接根据指导信息完成任务拆解或标签推荐,而不是再次调用 LLM - 这样可以避免嵌套 LLM 调用,大幅提升响应速度 **注意事项:** - 操作成功后简洁地告知用户结果 - 如果操作失败,说明原因并提供建议 - 列出待办时,按优先级和时间排序展示 - 任务拆解时,确保子任务具体、可执行,并包含合理的执行顺序 ================================================ FILE: lifetrace/config/prompts/agno_tools/zh/stats.yaml ================================================ # 统计分析工具消息 - 中文版 # 统计 stats_header: "待办统计 ({date_range}):\n" stats_total: "- 总数: {total}" stats_completed: "- 已完成: {completed}" stats_active: "- 进行中: {active}" stats_overdue: "- 已逾期: {overdue}" stats_by_priority: "- 按优先级: 高({high}) 中({medium}) 低({low}) 无({none})" stats_failed: "获取统计失败: {error}" # 逾期 overdue_header: "逾期待办 (共 {count} 项):\n" overdue_item: "- #{id} {name} (逾期 {days} 天)" no_overdue: "没有逾期的待办事项" ================================================ FILE: lifetrace/config/prompts/agno_tools/zh/tags.yaml ================================================ # 标签管理工具消息 - 中文版 # 标签列表 tags_header: "所有标签 (共 {count} 个):\n" tags_item: "- {tag} ({count} 个待办)" tags_empty: "暂无标签" # 按标签查询 todos_by_tag_header: "标签 \"{tag}\" 下的待办 (共 {count} 项):\n" todos_by_tag_item: "- #{id} [{status}] {name}" todos_by_tag_empty: "标签 \"{tag}\" 下没有待办" # 标签推荐指导(用于 Agent 直接推荐,避免嵌套 LLM 调用) suggest_tags_guide: | 请根据以下待办名称,推荐 3-5 个合适的标签。 **待办名称:** {todo_name} **已有标签(供参考,可复用或创建新标签):** {existing_tags} **推荐要求:** 1. 推荐 3-5 个相关标签 2. 优先考虑复用已有标签 3. 如果已有标签不合适,可以创建新标签 4. 标签应该简洁、有意义、便于分类 请直接推荐标签并展示结果,无需调用其他工具。格式:推荐标签: 标签1, 标签2, 标签3 # 保留旧的消息键以兼容性(已废弃,但保留以防引用) suggest_tags_prompt: | 根据以下待办名称,推荐 3-5 个合适的标签。 **待办名称:** {todo_name} **已有标签(供参考):** {existing_tags} 请返回 JSON 格式: ```json {{"suggested_tags": ["标签1", "标签2", "标签3"]}} ``` suggest_tags_result: "推荐标签: {tags}" suggest_tags_failed: "标签推荐失败: {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/zh/time.yaml ================================================ # 时间解析工具消息 - 中文版 parse_time_success: "解析结果: {result}" parse_time_failed: "无法解析时间表达式 \"{expression}\": {error}" ================================================ FILE: lifetrace/config/prompts/agno_tools/zh/todo.yaml ================================================ # Todo 管理工具消息 - 中文版 # 创建 create_success: "成功创建待办 #{id}: {name}" create_failed: "创建待办失败: {error}" # 完成 complete_success: "已将待办 #{id} 标记为完成" complete_not_found: "未找到待办 #{id}" complete_failed: "完成待办失败: {error}" # 更新 update_success: "已更新待办 #{id}" update_not_found: "未找到待办 #{id}" update_failed: "更新待办失败: {error}" # 列表 list_header: "待办列表 ({status}, 共 {count} 项):\n" list_item: "- #{id} [{priority}] {name}" list_item_with_time: " (时间: {time})" list_empty: "没有找到{status}的待办事项" # 搜索 search_header: "搜索 \"{keyword}\" 的结果 (共 {count} 项):\n" search_item: "- #{id} [{status}] {name}" search_empty: "未找到包含 \"{keyword}\" 的待办" # 删除 delete_success: "已删除待办 #{id}" delete_not_found: "未找到待办 #{id}" delete_failed: "删除待办失败: {error}" ================================================ FILE: lifetrace/config/prompts/audio.yaml ================================================ # LifeTrace AI Prompt 配置文件 - 音频转录相关 # 包含转录文本优化、待办和日程提取的提示词 # ==================================== # 转录文本优化服务提示词 # ==================================== transcription_optimization: # 文本优化系统提示词 system_assistant: | 你是一个专业的文本优化助手,擅长优化语音转录文本,使其更加流畅、准确、易读。 你的任务是: 1. 修正语音识别中的错误 2. 补充缺失的标点符号 3. 优化语句结构,使其更符合书面语习惯 4. 保持原意不变 5. 保持自动分段格式(每段一行) 请用中文回答,保持准确和简洁。 # 文本优化用户提示词模板 user_prompt: | 请优化以下语音转录文本,使其更加流畅、准确、易读。 **转录文本:** {text} **要求:** 1. **修正识别错误**:纠正语音识别中的明显错误,如错别字、同音字等 2. **补充标点符号**:在适当位置添加标点符号,使文本更易读 3. **优化语句结构**:调整语句结构,使其更符合书面语习惯,但保持原意 4. **保持分段格式**:保持原有的分段格式(每段一行),不要合并段落 5. **保持原意**:不要改变文本的原始含义和关键信息 **注意事项:** - 如果文本已经是分段格式(每段一行),请保持这种格式 - 不要添加额外的解释或说明 - 只返回优化后的文本,不要其他内容 # ==================================== # 待办和日程提取服务提示词 # ==================================== transcription_extraction: # 提取系统提示词 system_assistant: | 你是一个专业的任务和日程提取助手,擅长从语音转录文本中识别待办事项和日程安排。 你的任务是: 1. 识别用户明确承诺、计划或讨论的待办事项 2. 识别用户提到的日程安排和时间约定 3. 提取待办事项的标题、描述和开始时间(如果有) 4. 提取日程安排的标题、时间和描述(如果有) 5. **宽松提取原则**:即使不是100%确定,只要有一定可能性,就可以提取 请用中文回答,保持准确和简洁。 # 提取用户提示词模板 user_prompt: | 请从以下转录文本中提取待办事项和日程安排。 **转录文本:** {text} **提取要求(宽松原则):** 1. **待办事项提取范围**: - 明确承诺:"我会..."、"我明天..."、"我答应..."、"好的,我..."等 - 计划讨论:"我们可能需要..."、"应该要..."、"记得..."等 - 任务分配:"你负责..."、"我来处理..."等 - 时间约定:"明天见"、"下周讨论"等(如果涉及具体事项) 2. **日程安排提取范围**: - 会议安排:"明天下午3点开会"、"下周一讨论"等 - 约会安排:"明天下午见"、"下周三见面"等 - 活动安排:"周末去..."、"下个月..."等 - 时间约定:任何明确提到具体时间的安排 3. **时间信息提取**: - 如果提到了具体时间(如"明天下午3点"、"下周一"、"13:00"),提取并分类为相对时间或绝对时间 - 相对时间:基于当前时间计算(如"明天"相对于今天) - 绝对时间:明确的日期时间(如"2024-01-15 13:00:00") - 如果没有明确时间,start_time/time可以为null 4. **高亮原文片段(非常重要,用于前端高亮显示)**: - 对于每一个待办(todo)或日程(schedule),请额外提供一个字段 **source_text** - 这个 **source_text** 必须是直接从「转录文本」中复制出来的一小段原文(不要自己改写、不要增加“计划”“需要”等前缀) - 这段原文应该是最能代表该待办/日程的关键片段,例如: - 原文:"早上八点准时起床。" -> source_text 可以是 "早上八点准时起床" - 原文:"中午十二点开始吃午饭。" -> source_text 可以是 "中午十二点开始吃午饭" - 如果一句话里包含多个待办/日程,可以为不同项选择同一句中的不同关键部分 - 如果确实找不到合适的原文片段,可以省略 source_text 字段 5. **置信度评估**: - 明确承诺:置信度高 - 计划讨论:置信度中等 - 可能的待办/日程:置信度较低 - 不确定时宁可提取并给较低置信度,让用户后续确认 **请以JSON格式返回:** {{ "todos": [ {{ "title": "待办标题(简洁,不超过20字)", "description": "待办描述(可选,详细说明)", "start_time": "开始时间(如果有,格式:YYYY-MM-DD HH:MM:SS 或相对时间描述)", "source_text": "直接从原始转录文本中复制出来的一小段文字,用于高亮(可选,但强烈建议提供)" }}, ... ], "schedules": [ {{ "title": "日程标题(简洁,不超过20字)", "time": "时间(格式:YYYY-MM-DD HH:MM:SS 或相对时间描述)", "description": "日程描述(可选,详细说明)", "source_text": "直接从原始转录文本中复制出来的一小段文字,用于高亮(可选,但强烈建议提供)" }}, ... ] }} 如果没有发现待办事项或日程安排,返回空数组: {{ "todos": [], "schedules": [] }} 只返回JSON,不要返回其他任何信息。 ================================================ FILE: lifetrace/config/prompts/chat.yaml ================================================ # LifeTrace AI Prompt 配置文件 - 前端聊天相关 # 包含前端聊天功能的提示词 # ==================================== # 前端聊天相关提示词 # ==================================== chat_frontend: # 编辑模式系统提示词(中文) edit_system_prompt_zh: | 你是一个待办编辑助手。根据用户的请求和关联待办的上下文,生成有用的内容。 **重要规则:** 1. 使用 ## 标题来分隔不同的内容块 2. **每个内容块的末尾必须添加 [append_to: ]**,推荐这段内容应该追加到哪个待办的备注中 3. 使用上下文中提供的待办ID(数字),根据内容相关性选择最合适的待办 4. 每个内容块都必须有推荐的目标待办,不能遗漏 **输出格式示例:** ## 项目概述 这是项目的主要目标和范围... [append_to: 123] ## 下一步行动 1. 完成需求分析 2. 安排会议 [append_to: 456] 注意:[append_to: xxx] 中的 xxx 必须是上下文中存在的待办ID数字。 # 编辑模式系统提示词(英文) edit_system_prompt_en: | You are a todo editing assistant. Generate helpful content based on the user's request and linked todos context. **Important Rules:** 1. Use ## headers to separate distinct content blocks 2. **Every content block MUST end with [append_to: ]** to recommend which todo this content should be appended to 3. Use the todo IDs (numbers) provided in context, choose the most relevant one based on content 4. Every block must have a recommended target todo, do not omit any **Output Format Example:** ## Project Overview This section describes the main goals and scope... [append_to: 123] ## Next Steps 1. Complete requirements analysis 2. Schedule meeting [append_to: 456] Note: The xxx in [append_to: xxx] must be an existing todo ID number from the context. # 任务规划系统提示词(中文) plan_system_prompt_zh: | 你是任务规划助手:请先简短说明,再输出一个 JSON 对象,字段为 todos(数组)。 每个 todo: name(必填)、description(可选)、tags(可选字符串数组)、start_time(可选 ISO 8601)、end_time(可选 ISO 8601)、order(可选数字,用于同级任务排序,从1开始递增)、subtasks(可选数组,结构同上)。 order 字段说明:同级任务按 order 升序排列,order 相同时按创建时间排序。请为同级任务分配合理的 order 值(如 1, 2, 3...),体现任务的逻辑顺序或优先级。 若用户只有单一意图,用 1 个根任务,其余步骤放到 subtasks;若存在多个不同意图,则使用多个根任务并在各自 subtasks 中细化。 无法生成待办时,返回空数组并解释原因。只输出一个 JSON,可用 ```json ``` 包裹,JSON 外可保留可读解释。 # 任务规划系统提示词(英文) plan_system_prompt_en: | You are a planning assistant: give a brief explanation, then output ONE JSON object with key `todos` (array). Each todo: name (required), description (optional), tags (optional string array), start_time (optional ISO 8601), end_time (optional ISO 8601), order (optional number for sorting sibling tasks, starting from 1), subtasks (optional array with same shape). Order field explanation: sibling tasks are sorted by order in ascending order, with creation time as fallback. Assign reasonable order values (1, 2, 3...) to sibling tasks to reflect logical sequence or priority. If the prompt has a single intent, produce one root todo and put steps in subtasks; if multiple distinct intents, use multiple root todos with their own subtasks. If nothing actionable, return an empty array but explain. Only one JSON, may be wrapped in ```json ```, natural text may appear outside. # 从消息中提取待办系统提示词(中文) message_todo_extraction_system_prompt_zh: | 你是一个专业的待办事项提取助手,擅长从对话消息中识别和提取待办事项。 你的任务是: 1. 仔细分析对话消息,识别其中提到的待办事项 2. 提取待办事项的名称(name)、描述(description)和标签(tags) 3. 只提取明确的、可执行的待办事项,避免提取过于模糊或不确定的内容 4. 如果提供了待办上下文信息,请参考这些信息,避免提取重复的待办 请用中文回答,保持准确和简洁。 # 从消息中提取待办用户提示词模板 message_todo_extraction_user_prompt_zh: | 请从以下对话消息中提取待办事项: **对话消息:** {messages_text} {todo_context_section} **要求:** 1. 只提取明确的、可执行的待办事项 2. 每个待办必须包含 name(名称)字段 3. 如果消息中包含了待办的详细描述或说明,提取到 description 字段。 - description 必须使用 Markdown 格式编写 - description 应包含以下三个部分: * **概要**:待办事项的简要概述 * **背景**:待办事项产生的背景和原因 * **目标**:待办事项要实现的目标,如可能,将目标拆解为具体的交付物 4. 如果消息中提到了标签,可以提取到 tags 字段(字符串数组) 5. 如果没有明确的标签,tags 可以为空数组 6. 如果没有明确的描述,description 可以为 null 7. 不要提取过于模糊或不确定的内容 8. 如果提供了待办上下文,避免提取与已有待办重复的内容 9. 请不要给用户输出待办的 ID,用户不需要看到这个 **请以JSON格式返回:** {{ "todos": [ {{ "name": "待办名称(简洁明确)", "description": "待办描述(可选,使用Markdown格式,包含概要、背景、目标三部分)", "tags": ["标签1", "标签2"] }} ] }} 如果没有发现待办事项,返回: {{ "todos": [] }} 只返回JSON,不要返回其他任何信息。 ================================================ FILE: lifetrace/config/prompts/llm.yaml ================================================ # LifeTrace AI Prompt 配置文件 - LLM 客户端相关 # LLM 客户端的提示词 # ==================================== # LLM 客户端相关提示词 # ==================================== llm_client: # 意图分类提示词 intent_classification: | 你是一个智能助手,专门用于分析用户意图。请严格按照JSON格式返回结果。 # 查询解析提示词 query_parsing: | 你是一个查询解析助手。用户会提供关于历史记录的查询,你需要从中提取以下信息: 1. 时间范围:开始时间和结束时间(如果有的话) 2. 应用名称:用户提到的具体应用程序(如微信、QQ、浏览器等) 3. 关键词:用户想要搜索的具体内容关键词,用数组形式返回。注意区分: - 功能描述词(如"聊天"、"浏览"、"编辑"等)不是搜索关键词 - 只有用户明确要搜索特定内容时才提取关键词(如"包含项目报告的文档"中的"项目报告") - 如果用户只是想查看某应用的活动记录而没有指定搜索内容,keywords应为null 4. 查询类型:总结、搜索、统计等 请以JSON格式返回结果,包含以下字段: {{ "start_date": "YYYY-MM-DD HH:MM:SS" 或 null, "end_date": "YYYY-MM-DD HH:MM:SS" 或 null, "app_names": ["应用名称1", "应用名称2"] 或 null, "keywords": ["关键词1", "关键词2"] 或 null, "query_type": "summary|search|statistics|other" }} 注意: - 如果没有明确的时间信息,start_date和end_date设为null - 如果时间是相对的(如"今天"、"昨天"、"上周"),请基于提供的当前时间转换为具体日期 - "今天"应该设置为当天的00:00:00到23:59:59 - 应用名称要标准化,请使用以下标准应用名称:微信、WeChat、QQ、钉钉、企业微信、飞书、Telegram、Discord、记事本、计算器、Word、Excel、PowerPoint、WPS、Chrome、Firefox、Edge、Safari、VS Code、VSCode、PyCharm、IntelliJ IDEA、网易云音乐、QQ音乐、VLC、Steam、Epic Games、任务管理器、命令提示符、PowerShell、360安全卫士、腾讯电脑管家、迅雷、百度网盘 - 关键词提取原则: * "查看今天微信聊天情况" -> keywords: null(聊天是功能描述) * "搜索包含会议的微信消息" -> keywords: ["会议"](会议是搜索目标) * "找到关于项目报告的文档" -> keywords: ["项目报告"](项目报告是搜索目标) - 只需要返回json 不要返回其他任何信息 # 摘要生成提示词 summary_generation: | 你是一个智能助手,专门帮助用户分析和总结历史记录数据。 用户会提供一个查询和相关的历史数据,你需要: 1. 理解用户的查询意图 2. 分析提供的历史数据 3. 生成准确、有用的总结 重要要求: - 在回答中引用具体的截图ID来源,格式为:[截图ID: xxx] - 当提到某个具体信息时,请标注它来自哪个截图 - 这样用户可以知道信息的具体来源 请用中文回答,保持简洁明了,重点突出关键信息。 ================================================ FILE: lifetrace/config/prompts/plan.yaml ================================================ # LifeTrace AI Prompt 配置文件 - Plan 功能相关 # 包含 Plan 问卷、Plan 总结和子任务生成的提示词 # ==================================== # Plan功能提示词 # ==================================== plan_questionnaire: # Plan选择题生成系统提示词 system_assistant: | 你是一个专业的任务规划助手,擅长通过提问来帮助用户厘清任务详情和逻辑。 你的任务是: 1. **首先仔细阅读任务上下文信息**(如果提供):包括当前任务自身的详细信息、父任务链、同级任务、子任务的名称、描述、用户笔记、截止日期、优先级、标签、状态等所有信息 2. **严格避免重复询问已经在上下文中明确的信息**:如果当前任务或其父任务、同级任务、子任务中已经包含了某些信息(如截止日期、优先级、标签、状态、描述等),绝对不要针对这些信息再次提问 3. 分析用户提供的任务名称,识别任务中可能存在的模糊点、关键决策点或需要明确的信息 4. **固定生成3个选择题**,帮助用户完善任务详情 5. 每个问题应该聚焦于任务的不同方面(如:目标、范围、优先级、时间要求、资源需求等) 6. **只针对当前任务特有的、在上下文中未明确的信息进行提问** 请用中文回答,保持问题简洁明了。 # Plan选择题生成用户提示词模板 user_prompt: | 请分析以下任务名称,生成**固定3个**选择题来帮助用户完善任务详情: **任务名称:** {todo_name} {context_info} **重要要求(必须严格遵守):** 1. **必须仔细阅读上下文信息**: - 如果提供了任务上下文信息,你必须完整阅读并理解其中的所有信息 - **首先查看"当前任务详细信息"**:这是要拆解的任务本身的完整信息,包括描述、用户笔记等 - 然后查看父任务链、同级任务、子任务的相关信息 - 包括但不限于:任务名称、描述、用户笔记、截止日期、优先级、标签、状态等 - 这些信息已经明确,不需要再次询问 2. **严格禁止重复询问已明确的信息**: - 如果上下文中已经包含了截止日期、优先级、标签、状态、描述等信息,**绝对不要**针对这些信息生成问题 - **特别重要**:如果当前任务本身已经有描述或用户笔记,不要询问任务目标、范围等已在描述中说明的内容 - 例如:如果当前任务已经有描述,不要询问任务的基本目标或范围 - 例如:如果父任务已经设置了截止日期,不要询问当前任务的截止日期 - 例如:如果同级任务已经标注了优先级,不要询问优先级相关的问题 3. **只针对未明确的信息提问**: - 重点关注当前任务特有的、在上下文中完全未提及的信息 - 或者需要进一步澄清和细化的细节 - 每个问题应该聚焦于任务的不同方面(如:目标、范围、优先级、时间要求、资源需求、依赖关系等) - 问题应该帮助用户明确任务的边界、关键决策点和执行细节 4. **问题格式要求**: - 每个问题提供3-5个选项 - **注意:前端会自动为每个问题添加"不知道/不重要"选项,用户可以选择此选项表示该问题对其不重要或无法回答** - **所有问题默认支持多选(不定项选择),用户可以选择多个选项** **请以JSON格式返回:** {{ "questions": [ {{ "id": "q1", "question": "问题文本", "options": ["选项1", "选项2", "选项3", "选项4"] }} ] }} 只返回JSON,不要返回其他任何信息。 plan_summary: # Plan总结和子任务生成系统提示词 system_assistant: | 你是一个专业的任务规划助手,擅长根据任务信息和用户回答生成详细的任务总结和可执行的子任务列表。 你的任务是: 1. 根据任务名称和用户的回答,生成详细的任务总结 2. 将任务拆解为具体的、可执行的子任务 3. 提取子任务的名称(name)、描述(description)和标签(tags) 4. 只生成明确的、可执行的子任务,避免生成过于模糊或不确定的内容 5. 参考任务上下文信息(如果提供),避免生成与已有待办重复的子任务 请用中文回答,保持准确和简洁。 # Plan总结和子任务生成用户提示词模板 user_prompt: | 请根据以下任务信息和用户回答,生成详细的任务总结和子任务列表: **任务名称:** {todo_name} **用户回答:** {answers_text} **要求:** 1. **子任务列表**: - **至少生成2个子任务**(这是最低要求) - 根据任务复杂度合理拆分,一般建议3-7个子任务,最多不超过10个 - 每个子任务必须包含 name(名称)字段,名称应简洁明确 - 如果子任务需要详细说明,提取到 description 字段: * description 必须使用 Markdown 格式编写 * description 应包含以下三个部分: - **概要**:子任务的简要概述 - **背景**:子任务产生的背景和原因 - **目标**:子任务要实现的目标,如可能,将目标拆解为具体的交付物 - 如果子任务有明确的标签,可以提取到 tags 字段(字符串数组) - 如果没有明确的标签,tags 可以为空数组 - 如果没有明确的描述内容,description 可以为 null - 每个子任务应该有 order 字段(数字,从1开始递增),用于同级任务排序 - 子任务之间可以有层级关系(通过subtasks字段嵌套) - 子任务应该按照执行顺序或逻辑关系组织 - 避免生成与已有待办重复的子任务 2. **任务总结**: - 基于任务名称和用户回答,生成详细的任务描述 - 总结应该依据切分的子任务分点书写,适当换行 - 使用Markdown格式,可以包含列表、加粗等格式 - 总结应该涵盖任务的目标、范围、关键要点和执行要点 - 长度控制在100-300字 **请以JSON格式返回:** {{ "summary": "任务总结(Markdown格式,100-300字)", "subtasks": [ {{ "name": "子任务名称(简洁明确)", "description": "子任务描述(可选,使用Markdown格式,包含概要、背景、目标三部分)", "tags": ["标签1", "标签2"], "order": 1, "subtasks": [] }} ] }} 只返回JSON,不要返回其他任何信息。 ================================================ FILE: lifetrace/config/prompts/rag.yaml ================================================ # LifeTrace AI Prompt 配置文件 - RAG 服务相关 # RAG (检索增强生成) 服务的提示词 # ==================================== # RAG 服务相关提示词 # ==================================== rag: # 系统帮助提示词 system_help: | 你是LifeTrace的智能助手。LifeTrace是一个生活轨迹记录和分析系统,主要功能包括: 1. 自动截图记录用户的屏幕活动 2. OCR文字识别和内容分析 3. 应用使用情况统计 4. 智能搜索和查询功能 请根据用户的问题提供有用的帮助信息。 # 通用对话提示词 general_chat: | 你是LifeTrace的智能助手,请以友好、自然的方式与用户对话。 如果用户需要查询数据或统计信息,请引导他们使用具体的查询语句。 # 历史数据分析提示词 history_analysis: | 你是一个智能助手,专门帮助用户分析和总结历史记录数据。 用户会提供一个查询和相关的历史数据,你需要: 1. 理解用户的查询意图 2. 分析提供的历史数据 3. 生成准确、有用的总结 **强制性要求 - 必须严格遵守:** - 每当引用或提到任何具体信息时,必须标注截图ID来源,格式为:[截图ID: xxx] - 不允许提及任何信息而不标注其来源截图ID - 如果历史数据中包含截图ID信息,必须在相关内容后立即添加引用 - 这是为了确保信息的可追溯性和准确性 - 示例:"用户在微信中发送了消息 [截图ID: 12345]" 请用中文回答,保持简洁明了,重点突出关键信息。 # 用户查询模板 user_query_template: | 用户查询:{query} 相关历史数据: {context} 请基于以上数据回答用户的查询。 ================================================ FILE: lifetrace/config/prompts/search.yaml ================================================ # LifeTrace AI Prompt 配置文件 - 搜索相关 # 包含联网搜索、Agent 工具调用的提示词 # ==================================== # 联网搜索相关提示词 # ==================================== web_search: # 系统提示词 system: | 你是一个联网搜索助手,专门基于互联网搜索结果回答用户的问题。 **重要要求:** 1. 你必须严格基于提供的搜索结果来回答问题,不要编造或猜测信息 2. 在回答中引用信息时,必须使用引用标记格式:[[1]]、[[2]] 等,数字对应搜索结果编号 3. 在回答的末尾,必须添加一个 "Sources:" 段落,列出所有引用的来源 4. Sources 段落的格式为: Sources: 1. 标题 (URL) 2. 标题 (URL) ... **输出格式示例:** 根据搜索结果,AI 领域最近有以下新进展 [[1]]: - 新模型发布... - 技术突破... 更多信息可参考相关报道 [[2]]。 Sources: 1. AI 最新进展 (https://example.com/article1) 2. 技术新闻 (https://example.com/article2) 请用中文回答,保持简洁明了,重点突出关键信息。 # 用户查询模板 user_template: | 用户查询:{query} 搜索结果: {sources_context} 请基于以上搜索结果回答用户的问题。记住: - 在回答中使用 [[n]] 格式标注引用 - 在回答末尾添加 Sources: 段落列出所有来源 # ==================================== # Agent 工具调用相关提示词 # ==================================== agent: # Agent 系统提示词 system: | 你是一个智能助手,可以使用工具来帮助用户完成任务。 **工作流程:** 1. 分析用户查询,判断是否需要使用工具 2. 如果需要,选择合适的工具并执行 3. 严格基于工具结果生成回答 **重要原则:** - 当用户需要实时信息、最新资讯、当前事件时,必须使用 web_search 工具 - 工具执行后,必须严格基于工具返回的搜索结果生成回答 - 不要使用过时的知识或猜测,只使用工具提供的实时搜索结果 - 如果工具结果中包含相关信息,必须优先使用这些信息,而不是依赖训练数据中的知识 - 如果工具结果不足,可以继续使用工具获取更多信息 - 最终回答要准确、有用,并标注信息来源 - 当工具结果与你的知识冲突时,以工具结果为准(工具结果代表最新的实时信息) # 工具选择提示词 tool_selection: | 分析用户查询,判断是否需要使用工具。 **可用工具:** {tools} **判断标准(必须严格遵守):** - 需要实时信息、最新资讯、当前事件、特定年份的信息 → **必须**使用 web_search - 查询中包含年份(如2025、2024等)→ **必须**使用 web_search - 查询特定排名、榜单、最新数据 → **必须**使用 web_search - 查询考研、招生、招聘、政策、法规、学校信息等需要最新信息的场景 → **必须**使用 web_search - 查询"最新"、"2024"、"2025"等时间相关关键词 → **必须**使用 web_search - 查询学校招生简章、考试大纲、真题获取等 → **必须**使用 web_search - 一般对话、已有知识、理论性问题 → 不使用工具 **重要:** - 当用户查询涉及需要最新信息的内容(如考研、招生、政策、排名、学校信息等)时,即使没有明确提到年份,也必须使用 web_search - **如果提供了待办事项上下文,必须结合待办上下文来理解用户需求,选择更精准的搜索关键词** - **搜索关键词应该综合考虑用户查询和待办上下文,确保搜索能够满足待办事项的具体需求** - 工具参数中的 query 应该结合用户查询和待办上下文,选择最合适的关键词,保持原意,不要修改年份 - 如果不确定是否需要工具,倾向于使用工具(宁可多搜索,不要漏掉最新信息) **返回格式(JSON):** {{ "use_tool": true/false, "tool_name": "工具名称" 或 null, "tool_params": {{"query": "搜索查询字符串"}} 或 {{}} }} 只返回 JSON,不要返回其他信息。 # 任务评估提示词 task_evaluation: | 评估工具执行结果是否足够回答用户的问题。 **用户查询:** {user_query} **工具结果摘要:** {tool_result} **判断标准:** - 如果工具结果已经包含足够信息 → 返回"完成" - 如果需要更多信息或结果不相关 → 返回"继续" 只返回"完成"或"继续"。 ================================================ FILE: lifetrace/config/prompts/summary.yaml ================================================ # LifeTrace AI Prompt 配置文件 - 摘要服务相关 # 包含事件摘要、活动摘要、任务摘要、上下文构建器的提示词 # ==================================== # 事件摘要服务提示词 # ==================================== event_summary: # 事件摘要系统提示词(用于单个事件的简短摘要) system_assistant: | 你是一个专业的事件摘要助手,擅长从屏幕截图的OCR文本中快速提取关键信息并生成简洁的事件摘要。 你的任务是: 1. 分析用户在应用中的操作内容,理解用户正在做什么 2. 从OCR文本中识别核心活动主题 3. 生成简洁有力的标题和摘要,突出关键信息 请用中文回答,保持简洁明了。 # 事件摘要用户提示词(用于单个事件的简短摘要) user_prompt: | 你是一个事件摘要助手。根据用户在应用中的操作截图OCR文本,生成简洁的标题和摘要。 **应用信息:** - 应用名称:{app_name} - 窗口标题:{window_title} - 时间范围:{start_time} 至 {end_time} **OCR文本内容:** {ocr_text} **要求:** 1. **生成标题(不超过10个字)**:概括用户在这段时间内的主要操作或活动 - 标题要简洁有力,一目了然 - 避免使用模糊词汇,尽量具体 - 例如:"编写代码"、"浏览网页"、"处理邮件"等 2. **生成摘要(不超过30个字)**:描述事件的关键内容 - 突出核心信息和关键操作 - 重要部分用**加粗**标记 - 如果涉及具体内容(如文档名、关键词等),优先提及 3. **处理原则:** - 如果OCR文本较杂乱,提取最重要的主题和关键词 - 忽略UI元素、菜单项等重复性内容,关注用户的实际操作 - 如果文本中包含明显的主题(如文档标题、聊天内容等),优先使用 **请以JSON格式返回:** {{ "title": "标题内容(不超过10字)", "summary": "摘要内容(不超过30字),**重点部分**" }} 只返回JSON,不要返回其他任何信息。 # ==================================== # 上下文构建器提示词 # ==================================== context_builder: # 数据分析基础提示词 data_analysis_base: | 你是一个智能助手,专门帮助用户分析和总结历史记录数据。 # 引用规范要求 citation_requirements: | **强制性要求 - 必须严格遵守:** - 每当引用或提到任何具体信息时,必须标注截图ID来源,格式为:[截图ID: xxx] - 不允许提及任何信息而不标注其来源截图ID - 如果历史数据中包含截图ID信息,必须在相关内容后立即添加引用 - 这是为了确保信息的可追溯性和准确性 - 示例:"用户在微信中发送了消息 [截图ID: 12345]" # 回答格式要求 response_format: | 请用中文回答,保持简洁明了,重点突出关键信息。 # ==================================== # 活动摘要服务提示词 # ==================================== activity_summary: # 活动摘要系统提示词 system_assistant: | 你是一个专业的活动摘要助手,擅长从多个按时间线组织的事件中提取关键信息,并生成结构化的活动总结。 你的任务是: 1. 分析时间段内的所有事件,理解它们之间的关系和主题 2. 按时间顺序组织事件,识别核心任务和关键活动 3. 提取重要的决策、讨论和成果 4. 生成简洁但全面的活动摘要,突出核心信息和进展 请用中文回答,保持结构清晰,重点突出。 # 活动摘要用户提示词模板 user_prompt: | 你是一个活动摘要助手。根据以下时间段内按时间线组织的事件,生成一个结构化的活动标题和摘要。 **时间范围:** {start_time} 至 {end_time} **事件数量:** {event_count} 个事件 **事件时间线:** {events_text} **要求:** 1. **生成活动标题(不超过50字)**:概括这段时间内的核心活动主题 2. **生成结构化活动摘要(不超过500字)**:按照以下结构组织内容,使用Markdown格式: ### **核心任务与项目** - 列出这段时间内进行的主要任务、项目或工作内容 - 对于每个核心任务,简要描述其内容和进展 - 重点部分用**加粗**标记 ### **关键活动与进展** - 按时间顺序总结重要的活动节点 - 突出关键决策、成果或里程碑 - 如果有多个相关事件,进行合并描述 ### **技术细节与实现** - 如果涉及技术工作,总结关键技术点、代码变更或实现细节 - 如果涉及问题解决,说明问题和解决方案 ### **下一步计划** - 基于当前进展,推断可能的下一步工作或待办事项 - 如果事件中明确提到了下一步计划,优先列出 3. **组织原则:** - 优先呈现最重要的任务和成果 - 按时间线理解事件的逻辑关系 - 如果事件主题相似,合并描述;如果主题不同,分类呈现 - 保持简洁,避免冗余信息 4. **格式要求:** - 使用Markdown格式 - 使用**加粗**突出重点内容 - 列表使用 `-` 符号 - 保持段落清晰,易于阅读 **请以JSON格式返回:** {{ "title": "活动标题(不超过50字)", "summary": "结构化活动摘要(Markdown格式,不超过500字)" }} 只返回JSON,不要返回其他任何信息。 ================================================ FILE: lifetrace/config/prompts/todo.yaml ================================================ # LifeTrace AI Prompt 配置文件 - 待办相关 # 包含待办提取、自动待办检测的提示词 # ==================================== # 待办提取服务提示词 # ==================================== todo_extraction: # 待办提取系统提示词 system_assistant: | 你是一个专业的待办事项提取助手,擅长从聊天记录、会议记录等截图中识别用户可能承诺的待办事项。 你的任务是: 1. 识别用户明确承诺、答应或可能要做的事项(包括讨论中的待办) 2. 提取待办事项的标题、描述和时间信息 3. 区分相对时间(如"明天"、"下周")和绝对时间(如"2024-01-15 13:00") 4. **宽松提取原则**:即使不是100%确定,只要有一定可能性是待办事项,就可以提取 5. **置信度评估**:根据确定性给出合理的置信度(0.5-0.9),不确定的可以给较低置信度(0.5-0.7) 请用中文回答,保持准确和简洁。 # 待办提取用户提示词模板 user_prompt: | 你是一个待办事项提取助手。请从以下应用对话/会议记录的截图中提取用户可能承诺的待办事项。 **应用信息:** - 应用名称:{app_name} - 窗口标题:{window_title} - 事件时间范围:{start_time} 至 {end_time} **提取要求(宽松原则):** 1. **提取范围扩大**:不仅提取明确承诺,也提取可能的待办事项,包括: - 明确承诺:"我会..."、"我明天..."、"我答应..."、"好的,我..."等 - 计划讨论:"我们可能需要..."、"应该要..."、"记得..."等 - 任务分配:"你负责..."、"我来处理..."等 - 时间约定:"明天见"、"下周讨论"等(如果涉及具体事项) 2. **提取时间信息**: - 如果提到了具体时间(如"明天下午3点"、"下周一"、"13:00"),提取并分类为相对时间或绝对时间 - 相对时间:基于事件时间范围计算(如"明天"相对于事件开始时间) - 绝对时间:明确的日期时间(如"2024-01-15 13:00:00") - 如果没有明确时间,time_info可以为null 3. **提取待办内容**:提取用户承诺、计划或讨论要做的具体事情 4. **置信度评估**: - 明确承诺:置信度 0.8-0.9 - 计划讨论:置信度 0.6-0.7 - 可能的待办:置信度 0.5-0.6 - 不确定时宁可提取并给较低置信度,让用户后续确认 **时间格式要求:** - 相对时间: - relative_days: 相对天数(0=今天,1=明天,2=后天) - relative_time: 24小时制时间字符串(如"13:00", "15:30") - raw_text: 原始时间文本(如"明天下午1点") - 绝对时间: - absolute_time: ISO 8601格式(如"2024-01-15T13:00:00") - raw_text: 原始时间文本 **请以JSON格式返回:** {{ "todos": [ {{ "title": "待办标题(简洁,不超过20字)", "description": "待办描述(可选,详细说明)", "time_info": {{ "time_type": "relative" 或 "absolute", "relative_days": 1 或 null, "relative_time": "13:00" 或 null, "absolute_time": "2024-01-15T13:00:00" 或 null, "raw_text": "原始时间文本(如:明天下午1点)" }}, "source_text": "来源文本片段(用于验证)", "confidence": 0.7 }} ] }} 如果没有发现待办事项,返回: {{ "todos": [] }} 只返回JSON,不要返回其他任何信息。 # ===== 自动待办检测 ===== auto_todo_detection: system_assistant: | 你是一个专业的待办事项检测助手,擅长从单张截图中识别用户可能承诺的新待办事项。 你的任务是: 1. 识别用户明确承诺、答应或可能要做的事项(包括讨论中的待办) 2. 提取待办事项的标题、描述和时间信息 3. 区分相对时间(如"明天"、"下周")和绝对时间(如"2024-01-15 13:00") 4. **宽松提取原则**:即使不是100%确定,只要有一定可能性是待办事项,就可以提取 5. **置信度评估**:根据确定性给出合理的置信度(0.5-0.9),不确定的可以给较低置信度(0.5-0.7) 6. **避免与已有待办重复**(对比标题和描述) 请用中文回答,保持准确和简洁。 user_prompt: | 请分析这张截图,检测用户新承诺的待办事项。 **要求(宽松原则):** 1. **提取范围扩大**:不仅提取明确承诺,也提取可能的待办事项,包括: - 明确承诺:"我会..."、"我明天..."、"我答应..."、"好的,我..."等 - 计划讨论:"我们可能需要..."、"应该要..."、"记得..."等 - 任务分配:"你负责..."、"我来处理..."等 - 时间约定:"明天见"、"下周讨论"等(如果涉及具体事项) 2. **避免重复**:不要提取与已有待办列表中相同或相似的待办事项 3. **提取时间信息**: - 如果提到了具体时间(如"明天下午3点"、"下周一"、"13:00"),提取并分类为相对时间或绝对时间 - 相对时间:基于当前时间计算(如"明天"相对于今天) - 绝对时间:明确的日期时间(如"2024-01-15 13:00:00") - 如果没有明确时间,time_info可以为null 4. **提取待办内容**:提取用户承诺、计划或讨论要做的具体事情 5. **置信度评估**: - 明确承诺:置信度 0.8-0.9 - 计划讨论:置信度 0.6-0.7 - 可能的待办:置信度 0.5-0.6 - 不确定时宁可提取并给较低置信度,让用户后续确认 **已有待办列表(请避免重复):** {existing_todos_json} **时间格式要求:** - 相对时间: - relative_days: 相对天数(0=今天,1=明天,2=后天) - relative_time: 24小时制时间字符串(如"13:00", "15:30") - raw_text: 原始时间文本(如"明天下午1点") - 绝对时间: - absolute_time: ISO 8601格式(如"2024-01-15T13:00:00") - raw_text: 原始时间文本 **请以JSON格式返回:** {{ "new_todos": [ {{ "title": "待办标题(简洁,不超过20字)", "description": "待办描述(可选,详细说明)", "time_info": {{ "time_type": "relative" 或 "absolute", "relative_days": 1 或 null, "relative_time": "13:00" 或 null, "absolute_time": "2024-01-15T13:00:00" 或 null, "raw_text": "原始时间文本(如:明天下午1点)" }}, "source_text": "来源文本片段(用于验证)", "confidence": 0.7 }} ] }} 如果没有发现新待办事项,返回: {{ "new_todos": [] }} 只返回JSON,不要返回其他任何信息。 ================================================ FILE: lifetrace/config/rapidocr_config.yaml ================================================ # RapidOCR 配置文件 - 性能优化版本 # 用于解决PyInstaller打包后配置文件缺失的问题 # 针对打包后性能进行优化 Global: use_angle_cls: false # 关闭角度分类器以提升速度 print_verbose: false min_height: 20 # 降低最小高度阈值 Det: use_cuda: false limit_side_len: 960 # 增加图像尺寸限制以提高精度 limit_type: min thresh: 0.4 # 提高阈值以减少误检 box_thresh: 0.6 # 提高框阈值 max_candidates: 500 # 减少候选框数量以提升速度 unclip_ratio: 1.8 # 优化展开比例 use_dilation: false score_mode: fast Cls: use_cuda: false cls_thresh: 0.8 # 降低分类阈值 Rec: use_cuda: false rec_batch_num: 8 # 增加批处理数量以提升效率 # 外部模型文件路径配置(用于PyInstaller打包优化) # 注意:路径相对于 models 目录,只使用文件名 Models: det_model_path: 'ch_PP-OCRv4_det_infer.onnx' rec_model_path: 'ch_PP-OCRv4_rec_infer.onnx' cls_model_path: 'ch_ppocr_mobile_v2.0_cls_infer.onnx' ================================================ FILE: lifetrace/core/__init__.py ================================================ ================================================ FILE: lifetrace/core/config_watcher.py ================================================ """ 配置变更监听与回调机制 提供配置变更时的回调注册和触发功能,用于: - LLM API Key 变更时重新初始化 RAG 服务 - 定时任务开关变更时暂停/恢复任务 - 其他需要响应配置变更的场景 """ from collections.abc import Callable from typing import Any from lifetrace.core.lazy_services import reinit_rag_service from lifetrace.jobs.job_manager import get_job_manager from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() # 配置变更回调注册表 # 格式: {config_key: [callback_func, ...]} _callbacks: dict[str, list[Callable[[Any, Any], None]]] = {} # 配置值快照(用于检测变更) _config_snapshot: dict[str, Any] = {} def on_config_change(key: str): """装饰器:注册配置变更回调 当指定的配置键发生变更时,回调函数将被调用。 回调函数签名:callback(old_value, new_value) Args: key: 配置键(支持点号分隔的嵌套键,如 "llm.api_key") Example: @on_config_change("llm.api_key") def on_llm_key_change(old_val, new_val): print(f"LLM API Key changed from {old_val} to {new_val}") """ def decorator(func: Callable[[Any, Any], None]): register_callback(key, func) return func return decorator def register_callback(key: str, callback: Callable[[Any, Any], None]): """注册配置变更回调 Args: key: 配置键 callback: 回调函数,签名为 callback(old_value, new_value) """ if key not in _callbacks: _callbacks[key] = [] if callback not in _callbacks[key]: _callbacks[key].append(callback) logger.debug(f"已注册配置变更回调: {key} -> {callback.__name__}") def unregister_callback(key: str, callback: Callable[[Any, Any], None]): """取消注册配置变更回调 Args: key: 配置键 callback: 要取消的回调函数 """ if key in _callbacks and callback in _callbacks[key]: _callbacks[key].remove(callback) logger.debug(f"已取消配置变更回调: {key} -> {callback.__name__}") def notify_config_change(key: str, old_value: Any, new_value: Any): """通知配置变更 触发已注册的所有回调函数。 Args: key: 配置键 old_value: 旧值 new_value: 新值 """ if key not in _callbacks: return logger.info(f"配置变更: {key} = {new_value} (原值: {old_value})") for callback in _callbacks[key]: try: callback(old_value, new_value) logger.debug(f"配置变更回调成功: {key} -> {callback.__name__}") except Exception as e: logger.error(f"配置变更回调失败: {key} -> {callback.__name__}: {e}") def take_snapshot(): """获取当前配置快照 在配置重载前调用,用于后续比对变更。 """ _config_snapshot.clear() # 记录所有已注册回调的配置键的当前值 for key in _callbacks: try: _config_snapshot[key] = settings.get(key) except KeyError: _config_snapshot[key] = None def detect_and_notify_changes(): """检测并通知配置变更 在配置重载后调用,比对快照与当前值,触发变更回调。 """ for key in _callbacks: old_value = _config_snapshot.get(key) try: new_value = settings.get(key) except KeyError: new_value = None if old_value != new_value: notify_config_change(key, old_value, new_value) def reload_with_callbacks() -> bool: """带回调的配置重载 1. 获取当前配置快照 2. 重载配置 3. 检测变更并触发回调 Returns: bool: 重载是否成功 """ # 获取快照 take_snapshot() # 重载配置 try: settings.reload() success = True except Exception: success = False if success: # 检测并通知变更 detect_and_notify_changes() return success # ============================================================ # 预定义的配置变更回调 # ============================================================ @on_config_change("llm.api_key") def _on_llm_api_key_change(_old_val: Any, _new_val: Any): """LLM API Key 变更时重新初始化 RAG 服务""" try: reinit_rag_service() logger.info("LLM API Key 变更,已重新初始化 RAG 服务") except Exception as e: logger.error(f"重新初始化 RAG 服务失败: {e}") @on_config_change("llm.base_url") def _on_llm_base_url_change(_old_val: Any, _new_val: Any): """LLM Base URL 变更时重新初始化 RAG 服务""" try: reinit_rag_service() logger.info("LLM Base URL 变更,已重新初始化 RAG 服务") except Exception as e: logger.error(f"重新初始化 RAG 服务失败: {e}") @on_config_change("jobs.recorder.enabled") def _on_recorder_toggle(_old_val: Any, new_val: Any): """录制器任务开关变更""" try: manager = get_job_manager() scheduler = manager.scheduler_manager if not scheduler: logger.warning("调度器未初始化,无法更新录制器任务状态") return if new_val: scheduler.resume_job("recorder_job") logger.info("录制器任务已启用") else: scheduler.pause_job("recorder_job") logger.info("录制器任务已暂停") except Exception as e: logger.error(f"变更录制器任务状态失败: {e}") @on_config_change("jobs.ocr.enabled") def _on_ocr_toggle(_old_val: Any, new_val: Any): """OCR 任务开关变更""" try: manager = get_job_manager() scheduler = manager.scheduler_manager if not scheduler: logger.warning("调度器未初始化,无法更新 OCR 任务状态") return if new_val: scheduler.resume_job("ocr_job") logger.info("OCR 任务已启用") else: scheduler.pause_job("ocr_job") logger.info("OCR 任务已暂停") except Exception as e: logger.error(f"变更 OCR 任务状态失败: {e}") @on_config_change("jobs.auto_todo_detection.enabled") def _on_auto_todo_detection_toggle(_old_val: Any, new_val: Any): """自动待办检测任务开关变更""" try: manager = get_job_manager() scheduler = manager.scheduler_manager if not scheduler: logger.warning("调度器未初始化,无法更新自动待办检测任务状态") return if new_val: scheduler.resume_job("auto_todo_detection_job") logger.info("自动待办检测任务已启用") else: scheduler.pause_job("auto_todo_detection_job") logger.info("自动待办检测任务已暂停") except Exception as e: logger.error(f"变更自动待办检测任务状态失败: {e}") @on_config_change("vector_db.enabled") def _on_vector_db_toggle(_old_val: Any, new_val: Any): """向量数据库开关变更""" try: if new_val: reinit_rag_service() logger.info("向量数据库已启用,重新初始化 RAG 服务") else: logger.info("向量数据库已禁用") except Exception as e: logger.error(f"变更向量数据库状态失败: {e}") ================================================ FILE: lifetrace/core/dependencies.py ================================================ """FastAPI 依赖注入模块 提供数据库会话和服务层的依赖注入工厂函数。 """ from collections.abc import Generator from functools import lru_cache from fastapi import Depends from sqlalchemy.orm import Session from lifetrace.core.lazy_services import ( get_rag_service as lazy_get_rag_service, ) from lifetrace.core.lazy_services import ( get_vector_service as lazy_get_vector_service, ) from lifetrace.repositories.interfaces import ( IActivityRepository, IChatRepository, IEventRepository, IJournalRepository, IOcrRepository, ITodoRepository, ) from lifetrace.repositories.sql_activity_repository import SqlActivityRepository from lifetrace.repositories.sql_chat_repository import SqlChatRepository from lifetrace.repositories.sql_event_repository import SqlEventRepository, SqlOcrRepository from lifetrace.repositories.sql_journal_repository import SqlJournalRepository from lifetrace.repositories.sql_todo_repository import SqlTodoRepository from lifetrace.services.activity_service import ActivityService from lifetrace.services.chat_service import ChatService from lifetrace.services.event_service import EventService from lifetrace.services.journal_service import JournalService from lifetrace.services.todo_service import TodoService from lifetrace.storage.database_base import DatabaseBase from lifetrace.util.settings import settings def get_db_base() -> DatabaseBase: """获取数据库基础实例(复用 storage 模块的单例)""" from lifetrace.storage.database import db_base # noqa: PLC0415 return db_base def get_db_session( db_base: DatabaseBase = Depends(get_db_base), ) -> Generator[Session]: """获取数据库会话 - 请求级别生命周期""" if db_base.SessionLocal is None: raise RuntimeError("Database session factory is not initialized.") session = db_base.SessionLocal() try: yield session session.commit() except Exception: session.rollback() raise finally: session.close() # ========== Todo 模块依赖注入 ========== def get_todo_repository( db_base: DatabaseBase = Depends(get_db_base), ) -> ITodoRepository: """获取 Todo 仓库实例""" return SqlTodoRepository(db_base) def get_todo_service( repo: ITodoRepository = Depends(get_todo_repository), ) -> TodoService: """获取 Todo 服务实例""" return TodoService(repo) # ========== Journal 模块依赖注入 ========== def get_journal_repository( db_base: DatabaseBase = Depends(get_db_base), ) -> IJournalRepository: """获取 Journal 仓库实例""" return SqlJournalRepository(db_base) def get_journal_service( repo: IJournalRepository = Depends(get_journal_repository), db_base: DatabaseBase = Depends(get_db_base), ) -> JournalService: """获取 Journal 服务实例""" return JournalService(repo, db_base) # ========== Event 模块依赖注入 ========== def get_event_repository( db_base: DatabaseBase = Depends(get_db_base), ) -> IEventRepository: """获取 Event 仓库实例""" return SqlEventRepository(db_base) def get_ocr_repository( db_base: DatabaseBase = Depends(get_db_base), ) -> IOcrRepository: """获取 OCR 仓库实例""" return SqlOcrRepository(db_base) def get_event_service( event_repo: IEventRepository = Depends(get_event_repository), ocr_repo: IOcrRepository = Depends(get_ocr_repository), ) -> EventService: """获取 Event 服务实例""" return EventService(event_repo, ocr_repo) # ========== Activity 模块依赖注入 ========== def get_activity_repository( db_base: DatabaseBase = Depends(get_db_base), ) -> IActivityRepository: """获取 Activity 仓库实例""" return SqlActivityRepository(db_base) def get_activity_service( activity_repo: IActivityRepository = Depends(get_activity_repository), event_repo: IEventRepository = Depends(get_event_repository), ) -> ActivityService: """获取 Activity 服务实例""" return ActivityService(activity_repo, event_repo) # ========== Chat 模块依赖注入 ========== def get_chat_repository( db_base: DatabaseBase = Depends(get_db_base), ) -> IChatRepository: """获取 Chat 仓库实例""" return SqlChatRepository(db_base) def get_chat_service( repo: IChatRepository = Depends(get_chat_repository), ) -> ChatService: """获取 Chat 服务实例""" return ChatService(repo) # ========== 延迟加载服务 ========== def get_vector_service(): """获取向量服务(延迟加载)""" return lazy_get_vector_service() def get_rag_service(): """获取 RAG 服务(延迟加载)""" return lazy_get_rag_service() # ========== OCR 处理器依赖注入 ========== @lru_cache(maxsize=1) def get_ocr_processor(): """获取 OCR 处理器(延迟加载,单例模式)""" from lifetrace.jobs.ocr_processor import SimpleOCRProcessor # noqa: PLC0415 return SimpleOCRProcessor() # ========== 配置依赖注入 ========== def get_settings(): """获取配置对象""" return settings ================================================ FILE: lifetrace/core/lazy_services.py ================================================ """延迟加载服务模块 解决启动时同步加载向量服务和RAG服务导致的30秒+启动延迟问题。 服务在首次访问时才进行初始化。 """ from functools import lru_cache from typing import TYPE_CHECKING if TYPE_CHECKING: from lifetrace.llm.rag_service import RAGService from lifetrace.llm.vector_service import VectorService @lru_cache(maxsize=1) def get_vector_service() -> "VectorService": """延迟加载向量服务 - 首次访问时初始化""" from lifetrace.llm.vector_service import create_vector_service # noqa: PLC0415 return create_vector_service() @lru_cache(maxsize=1) def get_rag_service() -> "RAGService": """延迟加载 RAG 服务 - 首次访问时初始化""" from lifetrace.llm.rag_service import RAGService # noqa: PLC0415 return RAGService() def reinit_vector_service(): """重新初始化向量服务 在配置变更(如向量数据库设置变更)时调用。 """ get_vector_service.cache_clear() def reinit_rag_service(): """重新初始化 RAG 服务 在配置变更(如 LLM API Key 或 Base URL 变更)时调用。 同时也会重新初始化向量服务。 """ get_rag_service.cache_clear() get_vector_service.cache_clear() ================================================ FILE: lifetrace/core/module_registry.py ================================================ """Backend module registry for lightweight plugin-style enablement.""" from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass from importlib import import_module from importlib import util as importlib_util from typing import TYPE_CHECKING from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() if TYPE_CHECKING: from fastapi import FastAPI @dataclass(frozen=True) class ModuleDefinition: id: str router_module: str router_attr: str = "router" dependencies: tuple[str, ...] = () requires: tuple[str, ...] = () core: bool = False MODULES: tuple[ModuleDefinition, ...] = ( ModuleDefinition(id="health", router_module="lifetrace.routers.health", core=True), ModuleDefinition(id="config", router_module="lifetrace.routers.config", core=True), ModuleDefinition(id="system", router_module="lifetrace.routers.system", core=True), ModuleDefinition(id="logs", router_module="lifetrace.routers.logs"), ModuleDefinition(id="chat", router_module="lifetrace.routers.chat"), ModuleDefinition(id="activity", router_module="lifetrace.routers.activity"), ModuleDefinition(id="search", router_module="lifetrace.routers.search"), ModuleDefinition(id="screenshot", router_module="lifetrace.routers.screenshot"), ModuleDefinition(id="event", router_module="lifetrace.routers.event"), ModuleDefinition(id="ocr", router_module="lifetrace.routers.ocr"), ModuleDefinition( id="vector", router_module="lifetrace.routers.vector", dependencies=("chromadb", "sentence_transformers", "hdbscan", "scipy"), ), ModuleDefinition( id="rag", router_module="lifetrace.routers.rag", dependencies=("chromadb", "sentence_transformers", "hdbscan", "scipy"), requires=("vector",), ), ModuleDefinition(id="scheduler", router_module="lifetrace.routers.scheduler"), ModuleDefinition( id="automation", router_module="lifetrace.routers.automation", requires=("scheduler",), ), ModuleDefinition(id="cost_tracking", router_module="lifetrace.routers.cost_tracking"), ModuleDefinition(id="time_allocation", router_module="lifetrace.routers.time_allocation"), ModuleDefinition(id="todo", router_module="lifetrace.routers.todo"), ModuleDefinition(id="todo_extraction", router_module="lifetrace.routers.todo_extraction"), ModuleDefinition(id="journal", router_module="lifetrace.routers.journal"), ModuleDefinition(id="vision", router_module="lifetrace.routers.vision"), ModuleDefinition(id="notification", router_module="lifetrace.routers.notification"), ModuleDefinition(id="floating_capture", router_module="lifetrace.routers.floating_capture"), ModuleDefinition(id="audio", router_module="lifetrace.routers.audio"), ModuleDefinition(id="proactive_ocr", router_module="lifetrace.routers.proactive_ocr"), ) MODULE_INDEX = {module.id: module for module in MODULES} CORE_MODULES = {module.id for module in MODULES if module.core} @dataclass class ModuleState: id: str enabled: bool available: bool missing_deps: list[str] def _normalize_module_list(value: object | None) -> set[str]: if value is None: return set() if isinstance(value, str): return {value} if isinstance(value, Iterable): return {str(item) for item in value} return set() def _missing_dependencies(dependencies: tuple[str, ...]) -> list[str]: missing: list[str] = [] for dep in dependencies: if importlib_util.find_spec(dep) is None: missing.append(dep) return missing def _get_enabled_module_ids() -> set[str]: enabled = _normalize_module_list(settings.get("backend_modules.enabled")) disabled = _normalize_module_list(settings.get("backend_modules.disabled")) if not enabled: enabled = {module.id for module in MODULES} enabled = enabled.difference(disabled) enabled |= CORE_MODULES return enabled def get_module_states() -> dict[str, ModuleState]: enabled_ids = _get_enabled_module_ids() states: dict[str, ModuleState] = {} forced_unavailable = _normalize_module_list(settings.get("backend_modules.unavailable")) for module in MODULES: missing = _missing_dependencies(module.dependencies) states[module.id] = ModuleState( id=module.id, enabled=module.id in enabled_ids, available=not missing, missing_deps=missing, ) for module_id in forced_unavailable: state = states.get(module_id) if not state: continue if state.available: state.available = False state.missing_deps.append("config:unavailable") for module in MODULES: state = states[module.id] if state.available and module.requires: missing_requires = [ f"module:{req}" for req in module.requires if req not in states or not states[req].available ] if missing_requires: state.available = False state.missing_deps.extend(missing_requires) return states def log_module_summary(states: dict[str, ModuleState]) -> None: enabled_ids = sorted([mid for mid, state in states.items() if state.enabled]) disabled_ids = sorted([mid for mid, state in states.items() if not state.enabled]) unavailable_ids = sorted( [mid for mid, state in states.items() if state.enabled and not state.available] ) logger.info(f"Backend modules enabled: {', '.join(enabled_ids) or 'none'}") logger.info(f"Backend modules disabled: {', '.join(disabled_ids) or 'none'}") if unavailable_ids: logger.warning(f"Backend modules unavailable: {', '.join(unavailable_ids)}") for module_id in unavailable_ids: missing = ", ".join(states[module_id].missing_deps) or "unknown" logger.warning(f"Backend module deps missing: {module_id} -> {missing}") else: logger.info("Backend modules unavailable: none") def get_enabled_module_ids(states: dict[str, ModuleState] | None = None) -> list[str]: if states is None: states = get_module_states() return [module.id for module in MODULES if states[module.id].enabled] def register_modules( app: FastAPI, module_ids: Iterable[str], states: dict[str, ModuleState] | None = None, ) -> list[str]: if states is None: states = get_module_states() module_id_set = set(module_ids) enabled_modules: list[str] = [] for module in MODULES: if module.id not in module_id_set: continue state = states.get(module.id) if not state or not state.enabled: continue if not state.available: logger.warning( "Module disabled due to missing deps: %s -> %s", module.id, ", ".join(state.missing_deps), ) continue try: router_module = import_module(module.router_module) router = getattr(router_module, module.router_attr) app.include_router(router) enabled_modules.append(module.id) except Exception as exc: logger.error("Failed to register module %s: %s", module.id, exc) return enabled_modules def register_enabled_modules(app: FastAPI) -> list[str]: states = get_module_states() log_module_summary(states) enabled_ids = get_enabled_module_ids(states) return register_modules(app, enabled_ids, states=states) def get_capabilities_report() -> dict[str, object]: states = get_module_states() enabled_modules = [mid for mid, state in states.items() if state.enabled] available_modules = [mid for mid, state in states.items() if state.available] disabled_modules = [mid for mid, state in states.items() if not state.enabled] missing_deps = {mid: state.missing_deps for mid, state in states.items() if state.missing_deps} return { "enabled_modules": sorted(enabled_modules), "available_modules": sorted(available_modules), "disabled_modules": sorted(disabled_modules), "missing_deps": missing_deps, } ================================================ FILE: lifetrace/docs/MIGRATION_GUIDE.md ================================================ # 数据库迁移指南 ## 问题:多个 Head 错误 当出现 `Multiple head revisions are present` 错误时,说明迁移链出现了分支。 ### 原因 多个迁移文件都基于同一个父版本,导致 Alembic 不知道应该应用哪个迁移。 ### 解决方法 1. **检查当前所有 head**: ```bash alembic heads ``` 2. **查看迁移历史**: ```bash alembic history ``` 3. **修复迁移链**: - 找到最新的迁移文件 - 修改新迁移文件的 `down_revision`,让它基于最新的 head - 或者合并多个 head(使用 `alembic merge`) ## 如何预防多个 Head ### ✅ 正确做法 1. **创建新迁移前,先检查当前 head**: ```bash alembic heads # 输出示例:remove_project_task (head) ``` 2. **创建新迁移时,使用当前 head 作为父版本**: ```bash alembic revision -m "描述" --head=remove_project_task ``` 3. **或者手动创建迁移文件时,确保 `down_revision` 指向最新的 head**: ```python revision: str = "new_revision_id" down_revision: str = "remove_project_task" # 使用最新的 head ``` ### ❌ 错误做法 1. **不要基于旧的迁移版本创建新迁移**: ```python # 错误:如果已经有更新的迁移,不要基于旧版本 down_revision: str = "4ca5036ec7c8" # 如果已经有 remove_project_task,不要用这个 ``` 2. **不要同时创建多个基于同一父版本的迁移**: - 这会导致多个 head - 应该按顺序创建,一个接一个 ## 迁移文件命名规范 推荐使用时间戳 + 描述的方式: ```bash alembic revision -m "add_file_path_to_audio_recordings" # 生成:20260119_123456_add_file_path_to_audio_recordings.py ``` 或者手动创建时使用有意义的 revision ID: ```python revision: str = "add_file_path_001" # 简短但唯一 ``` ## 迁移链示例 正确的迁移链应该是线性的: ``` cc25001eb19c (初始基线) ↓ 4ca5036ec7c8 (添加 context) ↓ remove_project_task (删除项目表) ↓ add_file_path_001 (添加 file_path) ← 当前 head ``` ## 合并多个 Head 如果已经出现了多个 head,可以使用 merge: ```bash # 1. 创建一个合并迁移 alembic merge -m "merge heads" heads # 2. 这会创建一个新的迁移文件,合并所有 head # 3. 然后运行升级 alembic upgrade head ``` ## 快速检查清单 创建新迁移前: - [ ] 运行 `alembic heads` 查看当前 head - [ ] 运行 `alembic current` 查看当前数据库版本 - [ ] 确保新迁移的 `down_revision` 指向最新的 head - [ ] 创建后运行 `alembic heads` 确认只有一个 head ## 常见问题 ### Q: 如何查看迁移链? A: `alembic history --verbose` ### Q: 如何回滚到特定版本? A: `alembic downgrade ` ### Q: 如何查看当前数据库版本? A: `alembic current` ### Q: 迁移文件冲突怎么办? A: 使用 `alembic merge` 合并多个 head ### Q: 迁移运行后表结构仍然不完整怎么办? A: 这种情况通常发生在: 1. 表是通过 `SQLModel.metadata.create_all()` 创建的,而不是迁移 2. 迁移只添加了部分列,但表缺少其他列 **解决方法**: 1. **快速修复**:使用修复脚本直接修改数据库 ```bash python lifetrace/scripts/fix_audio_recordings_table.py ``` 2. **正确方法**:修改迁移文件,添加所有缺失的列,然后: ```bash # 回滚迁移 alembic downgrade -1 # 重新运行迁移 alembic upgrade head ``` 3. **预防**:确保所有表都通过迁移创建,而不是 `create_all()` ### Q: 如何确保迁移包含所有列? A: 在迁移的 `upgrade()` 函数中: - 检查表是否存在,不存在则创建完整表 - 检查每个列是否存在,不存在则添加 - 为现有记录设置合理的默认值 ================================================ FILE: lifetrace/jobs/activity_aggregator.py ================================================ """ 活动聚合任务 定时聚合15分钟内的事件,使用LLM总结,存储到活动表 """ from datetime import datetime, timedelta from functools import lru_cache from lifetrace.llm.activity_summary_service import activity_summary_service from lifetrace.storage import activity_mgr from lifetrace.storage.models import Event from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 常量定义 LONG_EVENT_DURATION_MINUTES = 30 # 长事件判断标准(分钟) QUERY_LOOKBACK_HOURS = 1 # 查询回溯时间(小时) def is_long_event(event: Event) -> bool: """判断是否为长事件(>=30分钟) Args: event: 事件对象 Returns: 是否为长事件 """ if not event.end_time: return False duration = (event.end_time - event.start_time).total_seconds() return duration >= LONG_EVENT_DURATION_MINUTES * 60 def round_to_15_minutes(dt: datetime) -> datetime: """将时间向下取整到最近的15分钟边界 Args: dt: 原始时间 Returns: 取整后的时间 """ minutes = dt.minute rounded_minutes = (minutes // 15) * 15 return dt.replace(minute=rounded_minutes, second=0, microsecond=0) def group_short_events_by_window( events: list[Event], ) -> dict[datetime, list[Event]]: """将短事件按15分钟窗口分组 Args: events: 事件列表 Returns: 按窗口分组的字典,key为窗口开始时间,value为事件列表 """ grouped: dict[datetime, list[Event]] = {} for event in events: window_start = round_to_15_minutes(event.start_time) if window_start not in grouped: grouped[window_start] = [] grouped[window_start].append(event) return grouped def create_activity_for_long_event(event: Event) -> bool: """为长事件单独创建活动 Args: event: 长事件对象 Returns: 是否成功 """ try: # 检查是否已存在重叠的活动 if activity_mgr.activity_overlaps_with_event(event): logger.debug(f"事件 {event.id} 已存在重叠的活动,跳过") return False event_id = event.id end_time = event.end_time if event_id is None or end_time is None: if event_id is None: logger.warning("事件缺少ID,无法创建活动") return False # 准备事件数据(包含时间信息以支持时间线呈现) event_data = { "ai_title": event.ai_title or "", "ai_summary": event.ai_summary or "", "start_time": event.start_time, # 添加时间信息 } # 生成活动摘要 result = activity_summary_service.generate_activity_summary( events=[event_data], start_time=event.start_time, end_time=end_time, ) if not result: logger.warning(f"为长事件 {event.id} 生成摘要失败") return False # 创建活动记录 activity_id = activity_mgr.create_activity( start_time=event.start_time, end_time=end_time, ai_title=result["title"], ai_summary=result["summary"], event_ids=[event_id], ) if activity_id: logger.info(f"为长事件 {event.id} 创建活动 {activity_id}: {result['title']}") return True logger.error(f"为长事件 {event.id} 创建活动失败") return False except Exception as e: logger.error(f"为长事件 {event.id} 创建活动时出错: {e}", exc_info=True) return False def create_activity_for_window(window_start: datetime, window_events: list[Event]) -> bool: """为15分钟窗口内的短事件创建活动 Args: window_start: 窗口开始时间 window_events: 窗口内的事件列表 Returns: 是否成功 """ try: # 检查是否已存在活动记录 window_end = window_start + timedelta(minutes=15) if activity_mgr.activity_exists_for_time_window(window_start, window_end): logger.debug(f"窗口 {window_start} 已存在活动记录,跳过") return False # 准备事件数据(包含时间信息以支持时间线呈现) events_data = [] for event in window_events: events_data.append( { "ai_title": event.ai_title or "", "ai_summary": event.ai_summary or "", "start_time": event.start_time, # 添加时间信息 } ) # 生成活动摘要 result = activity_summary_service.generate_activity_summary( events=events_data, start_time=window_start, end_time=window_end, ) if not result: logger.warning(f"为窗口 {window_start} 生成摘要失败") return False # 创建活动记录 event_ids = [e.id for e in window_events if e.id is not None] if not event_ids: logger.warning(f"窗口 {window_start} 没有可用事件ID,跳过活动创建") return False activity_id = activity_mgr.create_activity( start_time=window_start, end_time=window_end, ai_title=result["title"], ai_summary=result["summary"], event_ids=event_ids, ) if activity_id: logger.info( f"为窗口 {window_start} 创建活动 {activity_id}: {result['title']},包含 {len(event_ids)} 个事件" ) return True else: logger.error(f"为窗口 {window_start} 创建活动失败") return False except Exception as e: logger.error(f"为窗口 {window_start} 创建活动时出错: {e}", exc_info=True) return False def _calculate_target_window(now: datetime) -> tuple[datetime, datetime] | None: """计算目标处理窗口 Args: now: 当前时间 Returns: (window_start, window_end) 或 None(如果窗口尚未完成) """ window_end = round_to_15_minutes(now) window_start = window_end - timedelta(minutes=15) safety_gap = timedelta(minutes=1) # 留出1分钟缓冲,避免正在结束的事件 if now < window_end + safety_gap: logger.info("当前窗口尚未完全结束,跳过本次聚合") return None return window_start, window_end def _filter_events_in_window( events: list[Event], window_start: datetime, window_end: datetime ) -> list[Event]: """过滤出落在目标窗口内的事件 Args: events: 事件列表 window_start: 窗口开始时间 window_end: 窗口结束时间 Returns: 窗口内的事件列表 """ events_in_window = [] for e in events: if not e.end_time: continue # 事件结束时间必须在窗口内;开始时间只需早于窗口结束即可 if window_start <= e.end_time <= window_end and e.start_time < window_end: events_in_window.append(e) return events_in_window def _separate_long_and_short_events( events: list[Event], ) -> tuple[list[Event], list[Event]]: """分离长事件和短事件 Args: events: 事件列表 Returns: (长事件列表, 短事件列表) """ long_events = [e for e in events if is_long_event(e)] short_events = [e for e in events if not is_long_event(e)] return long_events, short_events def _process_long_events(long_events: list[Event]) -> int: """处理长事件,为每个长事件单独创建活动 Args: long_events: 长事件列表 Returns: 成功处理的长事件数量 """ long_event_count = 0 for event in long_events: if activity_mgr.activity_exists_for_event(event): logger.debug(f"长事件 {event.id} 已关联到活动,跳过") continue if create_activity_for_long_event(event): long_event_count += 1 return long_event_count def _process_short_events(short_events: list[Event], window_start: datetime) -> tuple[int, int]: """处理短事件,按窗口聚合 Args: short_events: 短事件列表 window_start: 窗口开始时间 Returns: (成功处理的窗口数, 处理的事件数) """ unprocessed_short_events = [ e for e in short_events if not activity_mgr.activity_exists_for_event(e) ] if not unprocessed_short_events: return 0, 0 grouped_events = {window_start: unprocessed_short_events} window_count = 0 for ws, window_events in grouped_events.items(): if create_activity_for_window(ws, window_events): window_count += 1 return window_count, len(unprocessed_short_events) def execute_activity_aggregation_task(): """执行活动聚合任务 只处理“已结束的15分钟窗口”,避免在窗口刚开始时就生成活动导致未来事件无法合并。 """ try: logger.info("开始执行活动聚合任务") now = get_utc_now() window_result = _calculate_target_window(now) if not window_result: return window_start, window_end = window_result # 查询近1小时未处理事件 query_start_time = now - timedelta(hours=QUERY_LOOKBACK_HOURS) events = activity_mgr.get_unprocessed_events(query_start_time) if not events: logger.debug("无待处理事件,跳过") return # 过滤窗口内的事件 events_in_window = _filter_events_in_window(events, window_start, window_end) if not events_in_window: logger.debug(f"窗口 {window_start} ~ {window_end} 内无可处理事件,跳过") return logger.info(f"窗口 {window_start} ~ {window_end} 待处理事件 {len(events_in_window)} 个") # 分离长事件和短事件 long_events, short_events = _separate_long_and_short_events(events_in_window) logger.info(f"长事件: {len(long_events)} 个,短事件: {len(short_events)} 个") # 处理长事件 long_event_count = _process_long_events(long_events) logger.info(f"成功处理 {long_event_count} 个长事件") # 处理短事件 window_count, processed_event_count = _process_short_events(short_events, window_start) if processed_event_count > 0: logger.info( f"成功处理 {window_count} 个时间窗口,包含 {processed_event_count} 个短事件" ) logger.info("活动聚合任务执行完成") except Exception as e: logger.error(f"执行活动聚合任务失败: {e}", exc_info=True) # 全局单例(用于延迟初始化) @lru_cache(maxsize=1) def get_aggregator_instance(): """获取聚合器实例(用于初始化)""" return True # 不需要实际实例,只是占位 ================================================ FILE: lifetrace/jobs/clean_data.py ================================================ """ 数据清理任务 负责清理旧的截图数据,防止磁盘空间占用过大 """ import os from datetime import timedelta from functools import lru_cache from lifetrace.storage import get_session, screenshot_mgr from lifetrace.storage.models import Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.base_paths import get_user_data_dir from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now logger = get_logger() class CleanDataService: """数据清理服务""" def __init__(self): """初始化数据清理服务""" self.max_screenshots = settings.get("jobs.clean_data.params.max_screenshots") self.max_days = settings.get("jobs.clean_data.params.max_days") self.delete_file_only = settings.get("jobs.clean_data.params.delete_file_only") logger.info("数据清理服务已初始化") def execute(self) -> dict: """执行数据清理任务 Returns: 执行结果字典,包含清理统计信息 """ try: logger.info("开始执行数据清理任务") result = { "deleted_files": 0, "deleted_records": 0, "freed_space": 0, "errors": [], } # 1. 按数量清理(保留最新的 N 张截图) if self.max_screenshots: deleted = self._clean_by_count() result["deleted_files"] += deleted["files"] result["deleted_records"] += deleted["records"] result["freed_space"] += deleted["space"] # 2. 按时间清理(删除超过 N 天的数据) if self.max_days: deleted = self._clean_by_date() result["deleted_files"] += deleted["files"] result["deleted_records"] += deleted["records"] result["freed_space"] += deleted["space"] logger.info( f"数据清理完成 - 删除文件: {result['deleted_files']}, " f"删除记录: {result['deleted_records']}, " f"释放空间: {result['freed_space'] / 1024 / 1024:.2f}MB" ) return result except Exception as e: logger.error(f"执行数据清理任务失败: {e}", exc_info=True) return {"error": str(e)} def _clean_by_count(self) -> dict: """按数量清理截图 Returns: 清理结果统计 """ result = {"files": 0, "records": 0, "space": 0} try: # 获取截图总数(排除已删除文件的记录) total = screenshot_mgr.get_screenshot_count(exclude_deleted=True) if total <= self.max_screenshots: logger.debug( f"截图数量 ({total}) 未超过限制 ({self.max_screenshots}),跳过按数量清理" ) return result # 计算需要删除的数量 to_delete_count = total - self.max_screenshots logger.info( f"截图数量超限 ({total} > {self.max_screenshots}),需要删除 {to_delete_count} 张" ) # 获取最旧的截图列表(排除已删除文件的记录) with get_session() as session: old_screenshots = ( session.query(Screenshot) .filter(col(Screenshot.file_deleted).is_not(True)) .order_by(col(Screenshot.created_at).asc()) .limit(to_delete_count) .all() ) for screenshot in old_screenshots: deleted = self._delete_screenshot(screenshot, session) if deleted["success"]: result["files"] += 1 result["space"] += deleted["size"] if not self.delete_file_only: result["records"] += 1 return result except Exception as e: logger.error(f"按数量清理截图失败: {e}", exc_info=True) return result def _clean_by_date(self) -> dict: """按日期清理截图 Returns: 清理结果统计 """ result = {"files": 0, "records": 0, "space": 0} try: # 计算截止日期 cutoff_date = get_utc_now() - timedelta(days=self.max_days) logger.info(f"开始清理 {cutoff_date.strftime('%Y-%m-%d')} 之前的截图数据") # 获取需要清理的截图(排除已删除文件的记录) with get_session() as session: old_screenshots = ( session.query(Screenshot) .filter(col(Screenshot.created_at) < cutoff_date) .filter(col(Screenshot.file_deleted).is_not(True)) .all() ) if not old_screenshots: logger.debug("没有超过保留期限的截图,跳过按日期清理") return result logger.info(f"找到 {len(old_screenshots)} 张过期截图") for screenshot in old_screenshots: deleted = self._delete_screenshot(screenshot, session) if deleted["success"]: result["files"] += 1 result["space"] += deleted["size"] if not self.delete_file_only: result["records"] += 1 return result except Exception as e: logger.error(f"按日期清理截图失败: {e}", exc_info=True) return result def _delete_screenshot(self, screenshot, session) -> dict: """删除单个截图 Args: screenshot: 截图对象 session: 数据库会话 Returns: 删除结果字典 """ result = {"success": False, "size": 0} try: # 构造完整路径 base_dir = str(get_user_data_dir()) file_path = os.path.join(base_dir, screenshot.file_path) # 删除文件 if os.path.exists(file_path): file_size = os.path.getsize(file_path) os.remove(file_path) result["size"] = file_size logger.debug(f"已删除文件: {file_path}") # 检查是否已经标记为已删除 elif getattr(screenshot, "file_deleted", False): logger.debug(f"文件已在之前被删除: {file_path}") else: logger.warning(f"文件不存在: {file_path}") # 如果配置为同时删除记录,则从数据库中删除 if not self.delete_file_only: session.delete(screenshot) session.flush() logger.debug(f"已删除数据库记录: screenshot_id={screenshot.id}") else: # 如果只删除文件,标记数据库记录为已删除(前端可根据此标识显示占位图) screenshot.file_deleted = True session.flush() logger.debug(f"已标记文件为已删除: screenshot_id={screenshot.id}") result["success"] = True except Exception as e: logger.error(f"删除截图失败 (id={screenshot.id}): {e}") return result # 全局单例 @lru_cache(maxsize=1) def get_clean_data_instance() -> CleanDataService: """获取数据清理服务单例""" return CleanDataService() def execute_clean_data_task(): """执行数据清理任务 - 供调度器调用的可序列化函数""" try: logger.info("开始执行数据清理任务") service = get_clean_data_instance() service.execute() logger.info("数据清理任务完成") except Exception as e: logger.error(f"执行数据清理任务失败: {e}", exc_info=True) ================================================ FILE: lifetrace/jobs/deadline_reminder.py ================================================ """ Todo 提醒调度(基于 APScheduler 的按时触发) """ from __future__ import annotations from datetime import datetime, timedelta from typing import Any, cast from sqlalchemy import or_ from lifetrace.jobs.scheduler import get_scheduler_manager from lifetrace.storage import todo_mgr from lifetrace.storage.models import Todo from lifetrace.storage.notification_storage import add_notification, is_notification_dismissed from lifetrace.storage.sql_utils import col from lifetrace.storage.todo_manager_utils import _normalize_reminder_offsets from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now, naive_as_utc logger = get_logger() MINUTES_PER_HOUR = 60 HOURS_PER_DAY = 24 REMINDER_JOB_PREFIX = "todo_reminder" def _normalize_offsets(value: object | None) -> list[int]: offsets = _normalize_reminder_offsets(value) if not offsets: return [] return offsets def _get_field(todo: object, name: str) -> Any: if isinstance(todo, dict): return todo.get(name) return getattr(todo, name, None) def _parse_datetime(value: str | None) -> datetime | None: if not value: return None try: parsed = datetime.fromisoformat(value) except (TypeError, ValueError): return None return naive_as_utc(parsed) def _coerce_datetime(value: Any) -> datetime | None: if isinstance(value, datetime): return value if isinstance(value, str): return _parse_datetime(value) return None def _resolve_schedule_time(todo: object) -> datetime | None: item_type_raw = _get_field(todo, "item_type") item_type = item_type_raw.upper() if isinstance(item_type_raw, str) else "VTODO" if item_type == "VEVENT": return _coerce_datetime( _get_field(todo, "dtstart") or _get_field(todo, "start_time") or _get_field(todo, "due") or _get_field(todo, "deadline") ) return _coerce_datetime( _get_field(todo, "due") or _get_field(todo, "deadline") or _get_field(todo, "dtstart") or _get_field(todo, "start_time") ) def _format_remaining(deadline: datetime, now: datetime) -> str: remaining_seconds = max(0, int((deadline - now).total_seconds())) minutes = remaining_seconds // MINUTES_PER_HOUR if minutes < MINUTES_PER_HOUR: return f"{minutes}分钟" hours = minutes // MINUTES_PER_HOUR if hours < HOURS_PER_DAY and minutes % MINUTES_PER_HOUR == 0: return f"{hours}小时" days = hours // HOURS_PER_DAY if days >= 1 and hours % HOURS_PER_DAY == 0: return f"{days}天" return f"{minutes}分钟" def _build_reminder_job_id(todo_id: int, reminder_at: datetime) -> str: return f"{REMINDER_JOB_PREFIX}_{todo_id}_{int(reminder_at.timestamp())}" def _build_notification_id(todo_id: int, reminder_at: datetime) -> str: return f"todo_{todo_id}_reminder_{int(reminder_at.timestamp())}" def execute_todo_reminder_job( todo_id: int, reminder_at: str, reminder_offset: int | None = None, ) -> None: """按时触发的提醒任务(由 APScheduler 直接调度)""" try: todo = todo_mgr.get_todo(todo_id) if not todo: logger.info("提醒任务跳过:todo 不存在: %s", todo_id) return if todo.get("status") != "active": logger.info("提醒任务跳过:todo 非 active: %s", todo_id) return schedule_time = _resolve_schedule_time(todo) if not schedule_time: logger.info("提醒任务跳过:todo 无有效时间: %s", todo_id) return schedule_utc = naive_as_utc(schedule_time) reminder_at_dt = _parse_datetime(reminder_at) or schedule_utc offset = reminder_offset if offset is None: offset = max(0, int((schedule_utc - reminder_at_dt).total_seconds() // 60)) expected_reminder_at = schedule_utc - timedelta(minutes=offset) if abs((expected_reminder_at - reminder_at_dt).total_seconds()) >= 1: logger.info( "提醒任务跳过:时间不匹配 todo_id=%s expected=%s actual=%s", todo_id, expected_reminder_at, reminder_at_dt, ) return if is_notification_dismissed(todo_id, reminder_at_dt): logger.debug( "提醒任务跳过:通知已取消 todo_id=%s reminder_at=%s", todo_id, reminder_at_dt, ) return now = get_utc_now() remaining = _format_remaining(schedule_utc, now) notification_id = _build_notification_id(todo_id, reminder_at_dt) added = add_notification( notification_id=notification_id, title=todo.get("name") or "", content=f"还有 {remaining}", timestamp=now, todo_id=todo_id, schedule_time=schedule_utc, reminder_at=reminder_at_dt, reminder_offset=offset, ) if added: logger.info( "生成提醒通知: todo_id=%s, name=%s, time=%s, offset=%s", todo_id, todo.get("name"), schedule_utc, offset, ) except Exception as e: logger.error("执行提醒任务失败: %s", e, exc_info=True) def schedule_todo_reminders(todo: object) -> list[str]: """为单个 Todo 创建按时提醒任务""" todo_id = _get_field(todo, "id") schedule_time = _resolve_schedule_time(todo) offsets = _normalize_offsets(_get_field(todo, "reminder_offsets")) scheduler = get_scheduler_manager() can_schedule = ( settings.get("jobs.deadline_reminder.enabled", False) and isinstance(todo_id, int) and _get_field(todo, "status") == "active" and schedule_time is not None and offsets and scheduler and scheduler.scheduler ) if not can_schedule: if isinstance(todo_id, int) and scheduler and not scheduler.scheduler: logger.warning("调度器未初始化,跳过提醒任务创建: todo_id=%s", todo_id) return [] todo_id = cast("int", todo_id) schedule_time = cast("datetime", schedule_time) schedule_utc = naive_as_utc(schedule_time) now = get_utc_now() grace_seconds = settings.get("scheduler.misfire_grace_time", 60) try: grace_seconds = int(grace_seconds) except (TypeError, ValueError): grace_seconds = 60 created_jobs: list[str] = [] for offset in offsets: reminder_at = schedule_utc - timedelta(minutes=offset) if reminder_at <= now: if (now - reminder_at).total_seconds() <= grace_seconds: reminder_at = now else: continue job_id = _build_reminder_job_id(todo_id, reminder_at) scheduler.add_date_job( func=execute_todo_reminder_job, job_id=job_id, name=f"todo_{todo_id}_reminder", run_date=reminder_at, replace_existing=True, todo_id=todo_id, reminder_at=reminder_at.isoformat(), reminder_offset=offset, ) created_jobs.append(job_id) return created_jobs def remove_todo_reminder_jobs(todo_id: int) -> int: """移除指定 Todo 的所有提醒任务""" scheduler = get_scheduler_manager() if not scheduler or not scheduler.scheduler: return 0 prefix = f"{REMINDER_JOB_PREFIX}_{todo_id}_" removed = 0 for job in scheduler.get_all_jobs(): if job.id.startswith(prefix) and scheduler.remove_job(job.id): removed += 1 if removed: logger.debug("已移除 %s 个提醒任务: todo_id=%s", removed, todo_id) return removed def refresh_todo_reminders(todo: object) -> list[str]: """刷新单个 Todo 的提醒任务(先清理再重建)""" todo_id = _get_field(todo, "id") if isinstance(todo_id, int): remove_todo_reminder_jobs(todo_id) return schedule_todo_reminders(todo) def clear_all_todo_reminder_jobs() -> int: """清理所有按时提醒任务""" scheduler = get_scheduler_manager() if not scheduler or not scheduler.scheduler: return 0 removed = 0 for job in scheduler.get_all_jobs(): if job.id.startswith(f"{REMINDER_JOB_PREFIX}_") and scheduler.remove_job(job.id): removed += 1 if removed: logger.info("清理提醒任务: %s", removed) return removed def sync_all_todo_reminders() -> int: """同步所有待办的提醒任务(启动时调用)""" if not settings.get("jobs.deadline_reminder.enabled", False): logger.info("DDL 提醒未启用,跳过同步") return 0 scheduler = get_scheduler_manager() if not scheduler or not scheduler.scheduler: logger.warning("调度器未初始化,跳过提醒同步") return 0 clear_all_todo_reminder_jobs() with todo_mgr.db_base.get_session() as session: todos = ( session.query(Todo) .filter( col(Todo.status) == "active", or_( col(Todo.due).isnot(None), col(Todo.dtstart).isnot(None), col(Todo.deadline).isnot(None), col(Todo.start_time).isnot(None), ), ) .all() ) created = 0 for todo in todos: created += len(schedule_todo_reminders(todo)) logger.info("提醒任务同步完成: %s", created) return created def execute_deadline_reminder_task() -> None: """兼容旧任务入口:执行一次提醒同步""" sync_all_todo_reminders() ================================================ FILE: lifetrace/jobs/job_manager.py ================================================ # ruff: noqa: PLC0415 """ 后台任务管理器 负责管理所有后台任务的启动、停止和配置更新 """ from functools import lru_cache from lifetrace.core.module_registry import get_module_states from lifetrace.jobs.scheduler import SchedulerManager, get_scheduler_manager from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() def _execute_capture_task(): from lifetrace.jobs.recorder import execute_capture_task return execute_capture_task() def _execute_todo_capture_task(): from lifetrace.jobs.todo_recorder import execute_todo_capture_task return execute_todo_capture_task() def _execute_ocr_task(): from lifetrace.jobs.ocr import execute_ocr_task return execute_ocr_task() def _execute_activity_aggregation_task(): from lifetrace.jobs.activity_aggregator import execute_activity_aggregation_task return execute_activity_aggregation_task() def _execute_clean_data_task(): from lifetrace.jobs.clean_data import execute_clean_data_task return execute_clean_data_task() def _execute_deadline_reminder_task(): from lifetrace.jobs.deadline_reminder import execute_deadline_reminder_task return execute_deadline_reminder_task() def _execute_proactive_ocr_task(): from lifetrace.jobs.proactive_ocr import execute_proactive_ocr_task return execute_proactive_ocr_task() def execute_audio_recording_status_check(): """音频录制状态检查任务(用于监控录音状态) 注意:音频录制实际上由前端WebSocket控制,此任务仅用于状态监控和日志记录 """ try: # 检查配置 enabled = settings.get("jobs.audio_recording.enabled", False) audio_is_24x7 = settings.get("audio.is_24x7", False) # 如果配置开启,记录状态(实际启动由前端控制) if enabled and audio_is_24x7: logger.debug("音频录制服务已启用(由前端WebSocket控制)") else: logger.debug("音频录制服务未启用") except Exception as e: logger.error(f"音频录制状态检查失败: {e}", exc_info=True) class JobManager: """后台任务管理器""" def __init__(self): """初始化任务管理器""" # 后台服务实例 self.scheduler_manager: SchedulerManager | None = None self.module_states = {} logger.info("任务管理器已初始化") def _get_scheduler(self) -> SchedulerManager | None: if not self.scheduler_manager: logger.warning("调度器未初始化,跳过任务配置") return None return self.scheduler_manager def _is_module_active(self, *module_ids: str) -> bool: """检查模块是否启用且依赖可用""" if not self.module_states: self.module_states = get_module_states() for module_id in module_ids: state = self.module_states.get(module_id) if not state or not state.enabled or not state.available: return False return True def start_all(self): """启动所有后台任务""" logger.info("开始启动所有后台任务") self.module_states = get_module_states() if not self._is_module_active("scheduler"): logger.warning("调度器模块未启用或依赖缺失,跳过后台任务启动") return # 启动调度器 self._start_scheduler() if not self.scheduler_manager: logger.warning("调度器启动失败,停止后台任务初始化") return # 启动录制器任务(事件处理已集成到录制器中,截图后立即处理) self._start_recorder_job() # 启动 Todo 专用录制器任务(与自动待办检测联动) self._start_todo_recorder_job() # 启动OCR任务 self._start_ocr_job() # 启动活动聚合任务 self._start_activity_aggregator() # 启动数据清理任务 self._start_clean_data_job() # 启动 DDL 提醒任务 self._start_deadline_reminder_job() # 启动用户自定义自动化任务 self._start_automation_tasks() # 启动主动OCR任务 self._start_proactive_ocr_job() # 启动音频录制状态检查任务 self._start_audio_recording_job() logger.info("所有后台任务已启动") def stop_all(self): """停止所有后台任务""" logger.error("正在停止所有后台任务") # 停止调度器(会自动停止所有调度任务) self._stop_scheduler() logger.error("所有后台任务已停止") def _start_scheduler(self): """启动调度器""" try: self.scheduler_manager = get_scheduler_manager() self.scheduler_manager.start() logger.info("调度器已启动") except Exception as e: logger.error(f"启动调度器失败: {e}", exc_info=True) def _stop_scheduler(self): """停止调度器""" if self.scheduler_manager: try: logger.error("正在停止调度器...") self.scheduler_manager.shutdown(wait=True) logger.error("调度器已停止") except Exception as e: logger.error(f"停止调度器失败: {e}") def _start_recorder_job(self): """启动录制器任务""" if not self._is_module_active("screenshot"): logger.info("截图模块未启用,跳过录制器任务") return enabled = settings.get("jobs.recorder.enabled") try: # 仅在启用时预先初始化,避免阻塞启动 if enabled: from lifetrace.jobs.recorder import get_recorder_instance get_recorder_instance() logger.info("录制器实例已初始化") scheduler = self._get_scheduler() if not scheduler: return # 添加录制器定时任务(使用可序列化的函数,无论是否启用都添加) recorder_interval = settings.get("jobs.recorder.interval") recorder_id = settings.get("jobs.recorder.id") scheduler.add_interval_job( func=_execute_capture_task, # 使用模块级别的函数 job_id="recorder_job", name=recorder_id, seconds=recorder_interval, replace_existing=True, ) logger.info(f"录制器定时任务已添加,间隔: {recorder_interval}秒") # 如果未启用,则暂停任务 if not enabled: scheduler.pause_job("recorder_job") logger.info("录制器服务未启用,已暂停") except Exception as e: logger.error(f"启动录制器任务失败: {e}", exc_info=True) def _start_todo_recorder_job(self): """启动 Todo 专用录制器任务 此任务与自动待办检测功能联动: - 仅在白名单应用激活时截图 - 截图后直接触发自动待办检测 - 与通用录制器完全独立 """ if not self._is_module_active("todo_extraction", "todo"): logger.info("待办提取模块未启用,跳过 Todo 录制器任务") return enabled = settings.get("jobs.todo_recorder.enabled", False) try: # 仅在启用时预先初始化 if enabled: from lifetrace.jobs.todo_recorder import get_todo_recorder_instance get_todo_recorder_instance() logger.info("Todo 录制器实例已初始化") scheduler = self._get_scheduler() if not scheduler: return # 添加 Todo 录制器定时任务(无论是否启用都添加) todo_recorder_interval = settings.get("jobs.todo_recorder.interval", 5) todo_recorder_id = settings.get("jobs.todo_recorder.id", "todo_recorder") scheduler.add_interval_job( func=_execute_todo_capture_task, job_id="todo_recorder_job", name=todo_recorder_id, seconds=todo_recorder_interval, replace_existing=True, ) logger.info(f"Todo 录制器定时任务已添加,间隔: {todo_recorder_interval}秒") # 如果未启用,则暂停任务 if not enabled: scheduler.pause_job("todo_recorder_job") logger.info("Todo 录制器服务未启用,已暂停") except Exception as e: logger.error(f"启动 Todo 录制器任务失败: {e}", exc_info=True) def _start_ocr_job(self): """启动OCR任务""" if not self._is_module_active("ocr"): logger.info("OCR 模块未启用,跳过 OCR 任务") return enabled = settings.get("jobs.ocr.enabled") try: scheduler = self._get_scheduler() if not scheduler: return # 添加OCR定时任务(无论是否启用都添加) ocr_interval = settings.get("jobs.ocr.interval") ocr_id = settings.get("jobs.ocr.id") scheduler.add_interval_job( func=_execute_ocr_task, job_id="ocr_job", name=ocr_id, seconds=ocr_interval, replace_existing=True, ) logger.info(f"OCR定时任务已添加,间隔: {ocr_interval}秒") # 如果未启用,则暂停任务 if not enabled: scheduler.pause_job("ocr_job") logger.info("OCR服务未启用,已暂停") except Exception as e: logger.error(f"启动OCR任务失败: {e}", exc_info=True) def _start_activity_aggregator(self): """启动活动聚合任务""" if not self._is_module_active("activity"): logger.info("活动模块未启用,跳过活动聚合任务") return enabled = settings.get("jobs.activity_aggregator.enabled") try: # 仅在启用时预先初始化 if enabled: from lifetrace.jobs.activity_aggregator import get_aggregator_instance get_aggregator_instance() logger.info("活动聚合服务实例已初始化") scheduler = self._get_scheduler() if not scheduler: return # 添加到调度器(无论是否启用都添加) interval = settings.get("jobs.activity_aggregator.interval") aggregator_id = settings.get("jobs.activity_aggregator.id") scheduler.add_interval_job( func=_execute_activity_aggregation_task, job_id="activity_aggregator_job", name=aggregator_id, seconds=interval, replace_existing=True, ) logger.info(f"活动聚合定时任务已添加,间隔: {interval}秒") # 如果未启用,则暂停任务 if not enabled: scheduler.pause_job("activity_aggregator_job") logger.info("活动聚合服务未启用,已暂停") except Exception as e: logger.error(f"启动活动聚合服务失败: {e}", exc_info=True) def _start_clean_data_job(self): """启动数据清理任务""" enabled = settings.get("jobs.clean_data.enabled") try: # 仅在启用时预先初始化 if enabled: from lifetrace.jobs.clean_data import get_clean_data_instance get_clean_data_instance() logger.info("数据清理服务实例已初始化") scheduler = self._get_scheduler() if not scheduler: return # 添加到调度器(无论是否启用都添加) interval = settings.get("jobs.clean_data.interval") clean_data_id = settings.get("jobs.clean_data.id") scheduler.add_interval_job( func=_execute_clean_data_task, job_id="clean_data_job", name=clean_data_id, seconds=interval, replace_existing=True, ) logger.info(f"数据清理定时任务已添加,间隔: {interval}秒") # 如果未启用,则暂停任务 if not enabled: scheduler.pause_job("clean_data_job") logger.info("数据清理服务未启用,已暂停") except Exception as e: logger.error(f"启动数据清理服务失败: {e}", exc_info=True) def _start_deadline_reminder_job(self): """启动 DDL 提醒任务""" if not self._is_module_active("todo", "notification"): logger.info("待办/通知模块未启用,跳过 DDL 提醒任务") return enabled = settings.get("jobs.deadline_reminder.enabled") try: scheduler = self._get_scheduler() if not scheduler: return # 清理旧的定时扫描任务(历史遗留) if scheduler.get_job("deadline_reminder_job"): scheduler.remove_job("deadline_reminder_job") logger.info("已移除旧的 DDL 提醒扫描任务") if not enabled: from lifetrace.jobs.deadline_reminder import clear_all_todo_reminder_jobs clear_all_todo_reminder_jobs() logger.info("DDL 提醒服务未启用,已清理提醒任务") return from lifetrace.jobs.deadline_reminder import sync_all_todo_reminders sync_all_todo_reminders() logger.info("DDL 提醒任务已同步") except Exception as e: logger.error(f"启动 DDL 提醒任务失败: {e}", exc_info=True) def _start_proactive_ocr_job(self): """启动主动OCR任务""" if not self._is_module_active("proactive_ocr"): logger.info("主动 OCR 模块未启用,跳过主动 OCR 任务") return enabled = settings.get("jobs.proactive_ocr.enabled", False) try: # 仅在启用时预先初始化 if enabled: from lifetrace.jobs.proactive_ocr.service import get_proactive_ocr_service get_proactive_ocr_service() logger.info("主动OCR服务实例已初始化") scheduler = self._get_scheduler() if not scheduler: return # 添加到调度器(无论是否启用都添加) interval = settings.get("jobs.proactive_ocr.interval", 1.0) proactive_ocr_id = settings.get("jobs.proactive_ocr.id", "proactive_ocr") scheduler.add_interval_job( func=_execute_proactive_ocr_task, job_id="proactive_ocr_job", name=proactive_ocr_id, seconds=interval, replace_existing=True, ) logger.info(f"主动OCR定时任务已添加,间隔: {interval}秒") # 如果未启用,则暂停任务 if not enabled: scheduler.pause_job("proactive_ocr_job") logger.info("主动OCR服务未启用,已暂停") else: # 如果启用,立即执行一次以启动服务 _execute_proactive_ocr_task() except Exception as e: logger.error(f"启动主动OCR任务失败: {e}", exc_info=True) def _start_automation_tasks(self): """启动用户自定义自动化任务""" if not self._is_module_active("automation", "scheduler"): logger.info("自动化模块未启用,跳过自动化任务") return scheduler = self._get_scheduler() if not scheduler: return try: from lifetrace.services.automation_task_service import AutomationTaskService AutomationTaskService().sync_all_tasks() logger.info("自动化任务同步完成") except Exception as e: logger.error(f"自动化任务同步失败: {e}", exc_info=True) def _start_audio_recording_job(self): """启动音频录制状态检查任务 注意:音频录制实际上由前端WebSocket控制,此任务仅用于状态监控 """ if not self._is_module_active("audio"): logger.info("音频模块未启用,跳过音频录制状态检查任务") return enabled = settings.get("jobs.audio_recording.enabled", False) try: scheduler = self._get_scheduler() if not scheduler: return # 添加到调度器(无论是否启用都添加) interval = settings.get("jobs.audio_recording.interval", 60) audio_recording_id = settings.get("jobs.audio_recording.id", "audio_recording") scheduler.add_interval_job( func=execute_audio_recording_status_check, job_id="audio_recording_job", name=audio_recording_id, seconds=interval, replace_existing=True, ) logger.info(f"音频录制状态检查任务已添加,间隔: {interval}秒") # 如果未启用,则暂停任务 if not enabled: scheduler.pause_job("audio_recording_job") logger.info("音频录制服务未启用,已暂停") else: logger.info("音频录制服务已启用(由前端WebSocket控制)") except Exception as e: logger.error(f"启动音频录制任务失败: {e}", exc_info=True) # 全局单例 @lru_cache(maxsize=1) def get_job_manager() -> JobManager: """获取任务管理器单例""" return JobManager() ================================================ FILE: lifetrace/jobs/ocr.py ================================================ """ LifeTrace 简化OCR处理器 参考 pad_ocr.py 设计,提供简单高效的OCR功能 """ import os import time from functools import lru_cache from lifetrace.core.lazy_services import get_vector_service as lazy_get_vector_service from lifetrace.storage import get_session from lifetrace.storage.models import OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_database_path from lifetrace.util.settings import settings from .ocr_config import DEFAULT_PROCESSING_DELAY, create_rapidocr_instance, get_ocr_config from .ocr_processor import ( RAPIDOCR_AVAILABLE, SimpleOCRProcessor, extract_text_from_ocr_result, preprocess_image, save_to_database, ) try: from rapidocr_onnxruntime import RapidOCR except ImportError: RapidOCR = None # 重新导出以保持向后兼容 __all__ = [ "RAPIDOCR_AVAILABLE", "SimpleOCRProcessor", "execute_ocr_task", "get_unprocessed_screenshots", "ocr_service", "process_screenshot_ocr", ] logger = get_logger() def get_unprocessed_screenshots(logger_instance=None, limit=50): """从数据库获取未处理OCR的截图记录 Args: logger_instance: 日志记录器,如果为None则使用模块级logger limit: 限制返回的记录数量,避免内存溢出 """ log = logger_instance if logger_instance is not None else logger try: with get_session() as session: unprocessed = ( session.query(Screenshot) .filter( ~col( session.query(OCRResult) .filter(col(OCRResult.screenshot_id) == col(Screenshot.id)) .exists() ) ) .order_by(col(Screenshot.created_at).desc()) .limit(limit) .all() ) log.info(f"查询到 {len(unprocessed)} 条未处理的截图记录") return [ { "id": screenshot.id, "file_path": screenshot.file_path, "created_at": screenshot.created_at, } for screenshot in unprocessed ] except Exception as e: log.error(f"查询未处理截图失败: {e}") return [] def process_screenshot_ocr(screenshot_info, ocr_engine, vector_service): """处理单个截图的OCR""" screenshot_id = screenshot_info["id"] file_path = screenshot_info["file_path"] try: if not os.path.exists(file_path): return False logger.info(f"开始处理截图 ID {screenshot_id}: {os.path.basename(file_path)}") start_time = time.time() img_array = preprocess_image(file_path) result, _ = ocr_engine(img_array) elapsed_time = time.time() - start_time ocr_config = get_ocr_config() ocr_text = extract_text_from_ocr_result(result, ocr_config["confidence_threshold"]) ocr_result = { "text_content": ocr_text, "confidence": ocr_config["default_confidence"], "language": ocr_config["language"], "processing_time": elapsed_time, } save_to_database(file_path, ocr_result, vector_service) logger.info(f"OCR处理完成 ID {screenshot_id}, 用时: {elapsed_time:.2f}秒") return True except Exception as e: logger.error(f"处理截图 {screenshot_id} 失败: {e}") return False @lru_cache(maxsize=1) def _get_ocr_engine(): """获取或初始化 OCR 引擎(带兜底配置)。""" logger.info("正在初始化RapidOCR引擎...") try: engine = create_rapidocr_instance() logger.info("RapidOCR引擎初始化成功") return engine except Exception as e: logger.error(f"RapidOCR初始化失败: {e}") if RapidOCR is None: raise try: logger.info("尝试使用最小配置重新初始化 RapidOCR...") engine = RapidOCR( config_path=None, det_use_cuda=False, cls_use_cuda=False, rec_use_cuda=False, print_verbose=False, ) logger.info("RapidOCR引擎使用最小配置初始化成功") return engine except Exception as fallback_error: logger.error(f"RapidOCR使用最小配置也初始化失败: {fallback_error}") raise def _ensure_ocr_initialized(): """确保OCR引擎已初始化(用于调度器模式)""" ocr_engine = _get_ocr_engine() vector_service = None try: logger.info("正在通过 lazy_services 初始化向量数据库服务...") vector_service = lazy_get_vector_service() if vector_service and vector_service.is_enabled(): logger.info("向量数据库服务已启用") else: logger.info("向量数据库服务未启用或不可用") except Exception as e: logger.error(f"初始化向量数据库服务失败: {e}") vector_service = None return ocr_engine, vector_service def execute_ocr_task(): """执行一次OCR处理任务(用于调度器调用) Returns: 处理成功的截图数量 """ try: ocr, vector_service = _ensure_ocr_initialized() unprocessed_screenshots = get_unprocessed_screenshots(logger) if not unprocessed_screenshots: logger.debug("没有待处理的截图") return 0 logger.info(f"发现 {len(unprocessed_screenshots)} 个未处理的截图") processed_count = 0 for screenshot_info in unprocessed_screenshots: success = process_screenshot_ocr(screenshot_info, ocr, vector_service) if success: processed_count += 1 time.sleep(DEFAULT_PROCESSING_DELAY) logger.info(f"OCR任务完成,成功处理 {processed_count} 张截图") return processed_count except Exception as e: logger.error(f"执行OCR任务失败: {e}") return 0 def ocr_service(): """主函数 - 基于数据库驱动的OCR处理(传统模式,独立运行)""" logger.info("LifeTrace 简化OCR处理器启动...") _ensure_database_initialized() check_interval = settings.get("jobs.ocr.interval") ocr, vector_service = _initialize_ocr_and_vector_service() logger.info(f"数据库检查间隔: {check_interval}秒") logger.info("开始基于数据库的OCR处理...") logger.info("按 Ctrl+C 停止服务") logger.info(f"OCR服务启动完成,检查间隔: {check_interval}秒") try: _run_ocr_loop(check_interval, ocr, vector_service) except KeyboardInterrupt: logger.error("收到停止信号,结束OCR处理") except Exception as e: logger.error(f"OCR处理过程中发生错误: {e}") raise Exception(e) from e finally: logger.error("OCR服务已停止") def _ensure_database_initialized() -> None: """确保数据库已初始化,否则抛出异常。""" if not get_database_path().exists(): raise Exception("数据库未初始化,无法启动OCR服务") def _initialize_ocr_and_vector_service(): """初始化 RapidOCR 引擎和向量数据库服务。""" try: ocr = _get_ocr_engine() except Exception as e: raise Exception(e) from e try: logger.info("正在通过 lazy_services 初始化向量数据库服务...") vector_service = lazy_get_vector_service() if vector_service and vector_service.is_enabled(): logger.info("向量数据库服务已启用") else: logger.info("向量数据库服务未启用或不可用") except Exception as e: logger.error(f"初始化向量数据库服务失败: {e}") vector_service = None return ocr, vector_service def _run_ocr_loop(check_interval: float, ocr, vector_service) -> None: """主循环:持续从数据库读取未处理截图并执行 OCR。""" processed_count = 0 while True: start_time = time.time() # noqa: F841 unprocessed_screenshots = get_unprocessed_screenshots(logger) if unprocessed_screenshots: logger.info(f"发现 {len(unprocessed_screenshots)} 个未处理的截图") for screenshot_info in unprocessed_screenshots: success = process_screenshot_ocr(screenshot_info, ocr, vector_service) if success: processed_count += 1 time.sleep(DEFAULT_PROCESSING_DELAY) else: time.sleep(check_interval) if __name__ == "__main__": ocr_service() logger.info("OCR服务已启动") ================================================ FILE: lifetrace/jobs/ocr_config.py ================================================ """ OCR 配置模块 包含 OCR 相关的常量、配置函数和初始化逻辑 """ import os import sys import yaml from lifetrace.util.base_paths import get_app_root, get_config_dir, get_models_dir from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() # OCR配置常量 DEFAULT_IMAGE_MAX_SIZE = (1920, 1080) DEFAULT_CONFIDENCE = 0.8 DEFAULT_PROCESSING_DELAY = 0.1 MIN_CONFIDENCE_THRESHOLD = 0.5 def get_application_path() -> str: """获取应用程序路径,兼容PyInstaller打包""" return str(get_app_root()) def get_rapidocr_config_path() -> str: """获取RapidOCR配置文件路径""" return str(get_config_dir() / "rapidocr_config.yaml") def setup_rapidocr_config(): """设置RapidOCR配置文件路径""" config_path = get_rapidocr_config_path() if os.path.exists(config_path): os.environ["RAPIDOCR_CONFIG_PATH"] = config_path logger.info(f"设置RapidOCR配置路径: {config_path}") else: logger.warning(f"配置文件不存在: {config_path}") def get_ocr_config() -> dict: """从配置中获取OCR相关参数 Returns: 包含OCR配置的字典 """ languages = settings.get("jobs.ocr.params.language") confidence_threshold = settings.get("jobs.ocr.params.confidence_threshold") language = languages[0] if isinstance(languages, list) and languages else "ch" if isinstance(languages, str): language = languages return { "confidence_threshold": confidence_threshold, "language": language, "default_confidence": DEFAULT_CONFIDENCE, } def create_rapidocr_instance(): """创建并初始化RapidOCR实例 Returns: RapidOCR实例 """ rapidocr_cls = _get_rapidocr_cls() if rapidocr_cls is None: raise ImportError("RapidOCR 未安装,请运行: pip install rapidocr-onnxruntime") setup_rapidocr_config() config_path = get_rapidocr_config_path() # 在 PyInstaller 打包环境中,清除可能干扰的环境变量 if getattr(sys, "frozen", False) and "RAPIDOCR_CONFIG_PATH" in os.environ: del os.environ["RAPIDOCR_CONFIG_PATH"] # 配置文件不存在时使用默认配置 if not os.path.exists(config_path): logger.warning(f"配置文件不存在: {config_path},使用默认配置") return _create_default_rapidocr(rapidocr_cls) logger.info(f"使用RapidOCR配置文件: {config_path}") try: with open(config_path, encoding="utf-8") as f: config_data = yaml.safe_load(f) if "Models" not in config_data: logger.info("未找到外部模型配置,使用默认方式") return _create_default_rapidocr_with_cleanup(rapidocr_cls) return _create_rapidocr_with_external_models(rapidocr_cls, config_data) except Exception as e: logger.error(f"读取配置文件失败: {e},使用默认配置") return _create_default_rapidocr_with_cleanup(rapidocr_cls) def _get_rapidocr_cls(): """延迟加载 RapidOCR 类,避免在启动时导入重依赖。""" try: from rapidocr_onnxruntime import RapidOCR # noqa: PLC0415 except ImportError: return None return RapidOCR def _create_default_rapidocr(rapidocr_cls): """创建默认配置的RapidOCR实例""" try: return rapidocr_cls( config_path=None, det_use_cuda=False, cls_use_cuda=False, rec_use_cuda=False, print_verbose=False, ) except Exception as e: logger.warning(f"RapidOCR 初始化时遇到问题: {e},尝试使用环境变量修复") if "RAPIDOCR_CONFIG_PATH" in os.environ: del os.environ["RAPIDOCR_CONFIG_PATH"] return rapidocr_cls( config_path=None, det_use_cuda=False, cls_use_cuda=False, rec_use_cuda=False, print_verbose=False, ) def _create_default_rapidocr_with_cleanup(rapidocr_cls): """在PyInstaller环境中清除环境变量后创建默认配置的RapidOCR实例""" if getattr(sys, "frozen", False) and "RAPIDOCR_CONFIG_PATH" in os.environ: del os.environ["RAPIDOCR_CONFIG_PATH"] return rapidocr_cls( config_path=None, det_use_cuda=False, cls_use_cuda=False, rec_use_cuda=False, print_verbose=False, ) def _create_rapidocr_with_external_models(rapidocr_cls, config_data: dict): """使用外部模型文件创建RapidOCR实例""" models_config = config_data["Models"] models_dir = get_models_dir() det_model_path = str(models_dir / models_config.get("det_model_path", "").lstrip("/")) rec_model_path = str(models_dir / models_config.get("rec_model_path", "").lstrip("/")) cls_model_path = str(models_dir / models_config.get("cls_model_path", "").lstrip("/")) if ( os.path.exists(det_model_path) and os.path.exists(rec_model_path) and os.path.exists(cls_model_path) ): logger.info("使用外部模型文件:") logger.info(f" 检测模型: {det_model_path}") logger.info(f" 识别模型: {rec_model_path}") logger.info(f" 分类模型: {cls_model_path}") return rapidocr_cls( det_model_path=det_model_path, rec_model_path=rec_model_path, cls_model_path=cls_model_path, det_use_cuda=False, cls_use_cuda=False, rec_use_cuda=False, print_verbose=False, ) else: logger.warning("外部模型文件不存在,使用默认配置") return _create_default_rapidocr_with_cleanup(rapidocr_cls) ================================================ FILE: lifetrace/jobs/ocr_processor.py ================================================ """ OCR 处理器模块 包含 SimpleOCRProcessor 类和图像处理相关函数 """ import contextlib import hashlib import os import time from typing import TYPE_CHECKING, Any from lifetrace.storage import get_session, ocr_mgr, screenshot_mgr from lifetrace.storage.models import OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from .ocr_config import DEFAULT_IMAGE_MAX_SIZE, create_rapidocr_instance, get_ocr_config logger = get_logger() if TYPE_CHECKING: import numpy as np RAPIDOCR_STATE: dict[str, bool | None] = {"available": None} RAPIDOCR_AVAILABLE = False _OCR_DEPS: dict[str, Any] = {} def _set_rapidocr_available(value: bool) -> None: RAPIDOCR_STATE["available"] = value globals()["RAPIDOCR_AVAILABLE"] = value def _load_ocr_deps() -> bool: """延迟加载 OCR 依赖,避免启动时阻塞。""" status = RAPIDOCR_STATE["available"] if status is not None: return bool(status) try: import numpy as np # noqa: PLC0415 from PIL import Image # noqa: PLC0415 from rapidocr_onnxruntime import RapidOCR # noqa: PLC0415 except ImportError: _set_rapidocr_available(False) logger.error("RapidOCR 未安装!请运行: pip install rapidocr-onnxruntime") return False _OCR_DEPS["np"] = np _OCR_DEPS["Image"] = Image _OCR_DEPS["RapidOCR"] = RapidOCR _set_rapidocr_available(True) return True def preprocess_image(image_path: str) -> "np.ndarray": """预处理图像,转换为RGB并缩放到合适大小 Args: image_path: 图像文件路径 Returns: 预处理后的图像数组 """ if not _load_ocr_deps(): raise RuntimeError("RapidOCR 未安装,无法处理图像") pil_image = _OCR_DEPS["Image"] np = _OCR_DEPS["np"] with pil_image.open(image_path) as image: rgb_image = image.convert("RGB") rgb_image.thumbnail(DEFAULT_IMAGE_MAX_SIZE, pil_image.Resampling.LANCZOS) return np.array(rgb_image) def extract_text_from_ocr_result(result, confidence_threshold: float | None = None) -> str: """从OCR结果中提取文本内容 Args: result: OCR识别结果 confidence_threshold: 置信度阈值,如果为None则从配置读取 Returns: 提取的文本内容 """ if confidence_threshold is None: raw_threshold = settings.get("jobs.ocr.params.confidence_threshold") confidence_threshold = float(raw_threshold) if raw_threshold is not None else 0.0 min_ocr_result_fields = 3 ocr_text = "" if result: for item in result: if len(item) >= min_ocr_result_fields: text = item[1] confidence = float(item[2]) if text and text.strip() and confidence > confidence_threshold: ocr_text += text.strip() + "\n" return ocr_text class SimpleOCRProcessor: """简化的OCR处理器类""" def __init__(self): self.ocr = None self.vector_service = None self.is_running = False def is_available(self): """检查OCR引擎是否可用""" return _load_ocr_deps() def start(self): """启动OCR处理服务""" self.is_running = True def stop(self): """停止OCR处理服务""" self.is_running = False def get_statistics(self): """获取OCR处理统计信息""" try: with get_session() as session: total_screenshots = session.query(Screenshot).count() ocr_results = session.query(OCRResult).count() unprocessed = total_screenshots - ocr_results return { "status": "running" if self.is_running else "stopped", "total_screenshots": total_screenshots, "processed": ocr_results, "unprocessed": unprocessed, "interval": settings.get("jobs.ocr.interval"), } except Exception as e: logger.error(f"获取OCR统计信息失败: {e}") return {"status": "error", "error": str(e)} def _ensure_ocr_initialized(self): """确保OCR引擎已初始化""" if self.ocr is None: self.ocr = create_rapidocr_instance() def process_image(self, image_path): """处理单个图像文件""" try: self._ensure_ocr_initialized() if self.ocr is None: raise RuntimeError("OCR engine is not initialized.") start_time = time.time() img_array = preprocess_image(image_path) result, _ = self.ocr(img_array) processing_time = time.time() - start_time ocr_config = get_ocr_config() ocr_text = extract_text_from_ocr_result(result, ocr_config["confidence_threshold"]) ocr_result = { "text_content": ocr_text, "confidence": ocr_config["default_confidence"], "language": ocr_config["language"], "processing_time": processing_time, } save_to_database(image_path, ocr_result, self.vector_service) return { "success": True, "text_content": ocr_text, "processing_time": processing_time, } except Exception as e: logger.error(f"处理图像失败: {e}") return {"success": False, "error": str(e)} def save_to_database(image_path: str, ocr_result: dict, vector_service=None): """保存OCR结果到数据库""" try: screenshot = screenshot_mgr.get_screenshot_by_path(image_path) if not screenshot: logger.info(f"为外部截图文件创建数据库记录: {image_path}") screenshot_id = create_screenshot_record(image_path) if not screenshot_id: logger.warning(f"无法为外部文件创建截图记录: {image_path}") return else: screenshot_id = screenshot["id"] ocr_result_id = ocr_mgr.add_ocr_result( screenshot_id=screenshot_id, text_content=ocr_result["text_content"], confidence=ocr_result["confidence"], language=ocr_result.get("language", "ch"), processing_time=ocr_result["processing_time"], ) screenshot_mgr.update_screenshot_processed(screenshot_id) if vector_service and vector_service.is_enabled() and ocr_result_id: _add_to_vector_database(ocr_result_id, screenshot_id, vector_service) except Exception as e: logger.error(f"保存OCR结果到数据库失败: {e}") def _add_to_vector_database(ocr_result_id: int, screenshot_id: int, vector_service): """将OCR结果添加到向量数据库""" try: with get_session() as session: ocr_obj = session.query(OCRResult).filter(col(OCRResult.id) == ocr_result_id).first() screenshot_obj = ( session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first() ) if ocr_obj: success = vector_service.add_ocr_result(ocr_obj, screenshot_obj) if success: logger.debug(f"OCR结果已添加到向量数据库: {ocr_result_id}") else: logger.warning(f"向量数据库添加失败: {ocr_result_id}") if screenshot_obj and getattr(screenshot_obj, "event_id", None): with contextlib.suppress(Exception): vector_service.upsert_event_document(screenshot_obj.event_id) except Exception as ve: logger.error(f"向量数据库操作失败: {ve}") def create_screenshot_record(image_path: str): """为外部截图文件创建数据库记录""" try: if not os.path.exists(image_path): return None if not _load_ocr_deps(): raise RuntimeError("RapidOCR 未安装,无法处理截图") pil_image = _OCR_DEPS["Image"] with open(image_path, "rb") as f: file_hash = hashlib.md5(f.read(), usedforsecurity=False).hexdigest() try: with pil_image.open(image_path) as img: width, height = img.size except Exception: width, height = 0, 0 filename = os.path.basename(image_path) app_name = "外部工具" window_title = filename if filename.startswith("Snipaste_"): app_name = "Snipaste" window_title = f"Snipaste截图 - {filename}" screenshot_id = screenshot_mgr.add_screenshot( file_path=image_path, file_hash=file_hash, width=width, height=height, metadata={ "screen_id": 0, "app_name": app_name, "window_title": window_title, }, ) return screenshot_id except Exception as e: logger.error(f"创建外部截图记录失败: {e}") return None ================================================ FILE: lifetrace/jobs/proactive_ocr/__init__.py ================================================ """Proactive OCR module for detecting and processing WeChat/Feishu windows""" from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from .service import get_proactive_ocr_service __all__ = ["get_proactive_ocr_service"] def execute_proactive_ocr_task(): """ 执行主动OCR任务 由调度器定期调用,检查配置并启动/停止服务 """ logger = get_logger() service = get_proactive_ocr_service() enabled = settings.get("jobs.proactive_ocr.enabled", False) # 如果配置启用但服务未运行,启动服务 if enabled and not service.is_running: try: service.start() logger.info("ProactiveOCR: Task triggered service start") except Exception as e: logger.error(f"ProactiveOCR: Failed to start service: {e}", exc_info=True) # 如果配置禁用但服务正在运行,停止服务 elif not enabled and service.is_running: try: service.stop() logger.info("ProactiveOCR: Task triggered service stop") except Exception as e: logger.error(f"ProactiveOCR: Failed to stop service: {e}", exc_info=True) ================================================ FILE: lifetrace/jobs/proactive_ocr/capture.py ================================================ """ 屏幕捕获模块 负责从目标窗口捕获画面帧 支持两种模式: 1. PrintWindow API - 可以捕获被遮挡的窗口(推荐,仅Windows) 2. MSS屏幕捕获 - 基于屏幕坐标,窗口被遮挡时会有问题(跨平台) """ import contextlib import importlib import platform import shutil import subprocess # nosec B404 import sys import time import uuid from typing import Any, cast import numpy as np from lifetrace.util.logging_config import get_logger from lifetrace.util.utils import _get_macos_active_window_bounds, get_active_window_info from .models import BBox, FrameEvent, ImageFrame, WindowMeta logger = get_logger() try: import mss import mss.tools except ImportError: mss = None logger.warning("mss not available, window capture will be limited") # Windows-specific imports if sys.platform == "win32": try: from ctypes import c_void_p, windll import win32con import win32gui import win32process win32ui = importlib.import_module("win32ui") WIN32_AVAILABLE = True except ImportError: c_void_p = None windll = None win32con = None win32gui = None win32process = None win32ui = None WIN32_AVAILABLE = False logger.warning("pywin32 not available, PrintWindow capture disabled") else: c_void_p = None windll = None win32con = None win32gui = None win32process = None win32ui = None WIN32_AVAILABLE = False try: import psutil except ImportError: psutil = None logger.warning("psutil not available, process name detection disabled") # Constants BGRA_CHANNELS = 4 # 设置DPI感知,解决高DPI缩放问题(仅Windows) def set_dpi_awareness(): """设置进程DPI感知模式""" if not WIN32_AVAILABLE or windll is None or c_void_p is None: return # Windows 10 1607+ 使用 SetProcessDpiAwarenessContext # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 with contextlib.suppress(Exception): windll.user32.SetProcessDpiAwarenessContext(c_void_p(-4)) return # Windows 8.1+ 使用 SetProcessDpiAwareness # PROCESS_PER_MONITOR_DPI_AWARE = 2 with contextlib.suppress(Exception): windll.shcore.SetProcessDpiAwareness(2) return # Windows Vista+ 使用 SetProcessDPIAware with contextlib.suppress(Exception): windll.user32.SetProcessDPIAware() # 在模块加载时设置DPI感知(仅Windows) if WIN32_AVAILABLE: set_dpi_awareness() class WindowCapture: """跨平台窗口捕获类""" def __init__(self, fps: float = 1.0): """ 初始化捕获器 Args: fps: 帧率,默认1fps """ self.fps = fps self.interval = 1.0 / fps self.last_capture_time = 0 self._sct = None self.platform = platform.system() def _get_mss(self): """获取mss实例""" if mss is None: return None if self._sct is None: self._sct = mss.mss() return self._sct def get_all_windows(self) -> list[WindowMeta]: """获取所有可见窗口(仅Windows)""" if not WIN32_AVAILABLE or win32gui is None or win32process is None: logger.warning("get_all_windows: Windows-only feature, returning empty list") return [] win32gui_local = cast("Any", win32gui) win32process_local = cast("Any", win32process) windows = [] def enum_callback(hwnd, results): if win32gui_local.IsWindowVisible(hwnd): title = win32gui_local.GetWindowText(hwnd) if title: # 只获取有标题的窗口 try: rect = win32gui_local.GetWindowRect(hwnd) _, pid = win32process_local.GetWindowThreadProcessId(hwnd) # 获取进程名 process_name = "" if psutil: try: process = psutil.Process(pid) process_name = process.name() except (psutil.NoSuchProcess, psutil.AccessDenied): pass # 检查是否最小化 is_minimized = bool(win32gui_local.IsIconic(hwnd)) window_meta = WindowMeta( hwnd=hwnd, title=title, process_name=process_name, pid=pid, rect=BBox( x=rect[0], y=rect[1], width=rect[2] - rect[0], height=rect[3] - rect[1], ), is_visible=True, is_minimized=is_minimized, ) results.append(window_meta) except Exception as e: logger.debug(f"Failed to get window info for hwnd {hwnd}: {e}") return True win32gui_local.EnumWindows(enum_callback, windows) return windows def get_foreground_window(self) -> WindowMeta | None: """获取当前前台窗口(跨平台)""" if self.platform == "Windows" and WIN32_AVAILABLE: return self._get_windows_foreground_window() elif self.platform == "Darwin": # macOS return self._get_macos_foreground_window() elif self.platform == "Linux": return self._get_linux_foreground_window() else: logger.warning(f"Unsupported platform: {self.platform}") return None def _get_windows_foreground_window(self) -> WindowMeta | None: """获取Windows前台窗口""" if not WIN32_AVAILABLE or win32gui is None or win32process is None: return None try: win32gui_local = cast("Any", win32gui) win32process_local = cast("Any", win32process) hwnd = win32gui_local.GetForegroundWindow() if not hwnd: return None title = win32gui_local.GetWindowText(hwnd) rect = win32gui_local.GetWindowRect(hwnd) _, pid = win32process_local.GetWindowThreadProcessId(hwnd) # 获取进程名 process_name = "" if psutil: try: process = psutil.Process(pid) process_name = process.name() except (psutil.NoSuchProcess, psutil.AccessDenied): pass return WindowMeta( hwnd=hwnd, title=title, process_name=process_name, pid=pid, rect=BBox( x=rect[0], y=rect[1], width=rect[2] - rect[0], height=rect[3] - rect[1], ), is_visible=True, is_minimized=bool(win32gui_local.IsIconic(hwnd)), ) except Exception as e: logger.error(f"Failed to get Windows foreground window: {e}") return None def _get_macos_foreground_window(self) -> WindowMeta | None: """获取macOS前台窗口""" try: # 获取活跃应用和窗口信息 app_name, window_title = get_active_window_info() if not app_name: return None # 获取窗口边界 bounds = _get_macos_active_window_bounds(app_name) if not bounds: # 如果没有找到窗口边界,使用默认值 bounds = {"X": 0, "Y": 0, "Width": 800, "Height": 600} # 获取进程ID pid = 0 if psutil: with contextlib.suppress(Exception): for proc in psutil.process_iter(["pid", "name"]): if proc.info["name"] == app_name or proc.info["name"] == f"{app_name}.app": pid = proc.info["pid"] break # macOS没有hwnd,使用pid作为标识 return WindowMeta( hwnd=pid, # 使用pid作为标识符 title=window_title or "", process_name=app_name, pid=pid, rect=BBox( x=int(bounds.get("X", 0)), y=int(bounds.get("Y", 0)), width=int(bounds.get("Width", 800)), height=int(bounds.get("Height", 600)), ), is_visible=True, is_minimized=False, # macOS难以检测最小化状态 ) except ImportError as e: logger.warning(f"macOS dependencies not available: {e}") return None except Exception as e: logger.error(f"Failed to get macOS foreground window: {e}") return None def _get_linux_foreground_window(self) -> WindowMeta | None: # noqa: C901 """获取Linux前台窗口""" try: # 获取活跃窗口信息 app_name, window_title = get_active_window_info() if not app_name: return None # 获取窗口位置和大小 try: # 使用xdotool获取活跃窗口 xdotool_path = shutil.which("xdotool") if not xdotool_path: raise FileNotFoundError("xdotool not found") result = subprocess.run( # nosec B603 [xdotool_path, "getactivewindow", "getwindowgeometry"], capture_output=True, text=True, timeout=2, check=False, ) if result.returncode == 0: # 解析窗口几何信息 geometry = {} for line in result.stdout.split("\n"): if "Position:" in line: pos = line.split("Position:")[1].strip().split()[0] x, y = map(int, pos.split(",")) geometry["x"] = x geometry["y"] = y elif "Geometry:" in line: size = line.split("Geometry:")[1].strip().split()[0] width, height = map(int, size.split("x")) geometry["width"] = width geometry["height"] = height # 获取窗口ID wid_result = subprocess.run( # nosec B603 [xdotool_path, "getactivewindow"], capture_output=True, text=True, timeout=2, check=False, ) window_id = int(wid_result.stdout.strip()) if wid_result.returncode == 0 else 0 # 获取进程ID pid = 0 if psutil: with contextlib.suppress(Exception): for proc in psutil.process_iter(["pid", "name"]): if proc.info["name"].lower() == app_name.lower(): pid = proc.info["pid"] break return WindowMeta( hwnd=window_id, title=window_title or "", process_name=app_name, pid=pid, rect=BBox( x=geometry.get("x", 0), y=geometry.get("y", 0), width=geometry.get("width", 800), height=geometry.get("height", 600), ), is_visible=True, is_minimized=False, # Linux难以检测最小化状态 ) except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): # xdotool不可用,使用默认值 logger.debug("xdotool not available, using default window bounds") return WindowMeta( hwnd=0, title=window_title or "", process_name=app_name, pid=0, rect=BBox(x=0, y=0, width=800, height=600), is_visible=True, is_minimized=False, ) except Exception as e: logger.error(f"Failed to get Linux foreground window: {e}") return None def capture_window(self, window: WindowMeta, use_printwindow: bool = True) -> ImageFrame | None: """ 捕获指定窗口的画面 Args: window: 窗口元数据 use_printwindow: 是否使用PrintWindow API(可捕获被遮挡窗口,仅Windows) Returns: 图像帧,如果捕获失败返回None """ if use_printwindow and WIN32_AVAILABLE: return self._capture_with_printwindow(window) else: return self._capture_with_mss(window) def _capture_with_printwindow(self, window: WindowMeta) -> ImageFrame | None: """ 使用PrintWindow API捕获完整窗口(可捕获被遮挡的窗口,仅Windows) """ if ( not WIN32_AVAILABLE or win32gui is None or win32ui is None or win32con is None or windll is None ): return self._capture_with_mss(window) try: win32gui_local = cast("Any", win32gui) win32ui_local = cast("Any", win32ui) hwnd = window.hwnd # 获取完整窗口大小(包含标题栏和边框) left, top, right, bottom = win32gui_local.GetWindowRect(hwnd) width = right - left height = bottom - top if width <= 0 or height <= 0: logger.warning(f"Invalid window size: {width}x{height}") return self._capture_with_mss(window) # 创建设备上下文 - 使用GetWindowDC获取整个窗口的DC hwnd_dc = win32gui_local.GetWindowDC(hwnd) mfc_dc = win32ui_local.CreateDCFromHandle(hwnd_dc) save_dc = mfc_dc.CreateCompatibleDC() # 创建位图 bitmap = win32ui_local.CreateBitmap() bitmap.CreateCompatibleBitmap(mfc_dc, width, height) save_dc.SelectObject(bitmap) # 使用PrintWindow捕获窗口内容 # PW_RENDERFULLCONTENT = 2,可以捕获DWM合成的内容(包含透明效果等) result = windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 2) if result == 0: # PrintWindow失败,尝试用BitBlt从屏幕DC复制 save_dc.BitBlt((0, 0), (width, height), mfc_dc, (0, 0), win32con.SRCCOPY) # 获取位图数据 bmp_info = bitmap.GetInfo() bmp_str = bitmap.GetBitmapBits(True) # 转换为numpy数组 img_array = np.frombuffer(bmp_str, dtype=np.uint8) img_array = img_array.reshape((bmp_info["bmHeight"], bmp_info["bmWidth"], 4)) # BGRA to RGB img_array = img_array[:, :, :3] # 去除alpha img_array = img_array[:, :, ::-1] # BGR to RGB # 清理资源 win32gui_local.DeleteObject(bitmap.GetHandle()) save_dc.DeleteDC() mfc_dc.DeleteDC() win32gui_local.ReleaseDC(hwnd, hwnd_dc) frame = ImageFrame( data=img_array, width=width, height=height, timestamp_ms=int(time.time() * 1000), capture_id=str(uuid.uuid4())[:8], ) return frame except Exception as e: logger.warning(f"PrintWindow capture failed: {e}, falling back to MSS") # 回退到mss捕获 return self._capture_with_mss(window) def _capture_with_mss(self, window: WindowMeta) -> ImageFrame | None: """ 使用MSS捕获屏幕区域(基于屏幕坐标,窗口被遮挡时会有问题) """ if mss is None: logger.error("MSS not available, cannot capture window") return None try: sct = self._get_mss() if sct is None: return None # 构建捕获区域 monitor = { "left": window.rect.x, "top": window.rect.y, "width": window.rect.width, "height": window.rect.height, } # 捕获屏幕 screenshot = sct.grab(monitor) # 转换为numpy数组 img_array = np.array(screenshot) # mss返回的是BGRA格式,转换为RGB if img_array.shape[2] == BGRA_CHANNELS: img_array = img_array[:, :, :3] # 去除alpha通道 # BGR to RGB img_array = img_array[:, :, ::-1] frame = ImageFrame( data=img_array, width=window.rect.width, height=window.rect.height, timestamp_ms=int(time.time() * 1000), capture_id=str(uuid.uuid4())[:8], ) return frame except Exception as e: logger.error(f"MSS capture failed: {e}") return None def capture_frame_event(self, window: WindowMeta) -> FrameEvent | None: """ 捕获帧事件 Args: window: 窗口元数据 Returns: 帧事件 """ frame = self.capture_window(window) if frame is None: return None return FrameEvent( frame=frame, window_meta=window, capture_id=frame.capture_id, ) def should_capture(self) -> bool: """检查是否应该捕获(基于fps限制)""" current_time = time.time() if current_time - self.last_capture_time >= self.interval: self.last_capture_time = current_time return True return False def cleanup(self): """清理资源""" if self._sct: self._sct.close() self._sct = None # 单例实例 _capture_state: dict[str, WindowCapture | None] = {"instance": None} def get_capture(fps: float = 1.0) -> WindowCapture: """获取捕获器单例""" instance = _capture_state["instance"] if instance is None: instance = WindowCapture(fps=fps) _capture_state["instance"] = instance return instance ================================================ FILE: lifetrace/jobs/proactive_ocr/models.py ================================================ """ 数据模型定义 定义系统中使用的核心数据结构 """ import time from dataclasses import dataclass, field from enum import Enum from typing import Any class AppType(Enum): """应用类型枚举""" WECHAT = "wechat" FEISHU = "feishu" UNKNOWN = "unknown" @dataclass class BBox: """边界框""" x: int y: int width: int height: int def to_tuple(self) -> tuple: """转换为元组格式 (x1, y1, x2, y2)""" return (self.x, self.y, self.x + self.width, self.y + self.height) @classmethod def from_tuple(cls, t: tuple) -> "BBox": """从元组创建 (x1, y1, x2, y2)""" return cls(x=t[0], y=t[1], width=t[2] - t[0], height=t[3] - t[1]) @dataclass class WindowMeta: """窗口元数据""" hwnd: int # 窗口句柄 title: str # 窗口标题 process_name: str # 进程名 pid: int # 进程ID rect: BBox # 窗口位置和大小 is_visible: bool = True # 是否可见 is_minimized: bool = False # 是否最小化 @dataclass class ImageFrame: """图像帧""" data: Any # 图像数据 (numpy array) width: int height: int timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000)) capture_id: str = "" @dataclass class FrameEvent: """帧事件""" frame: ImageFrame window_meta: WindowMeta capture_id: str @dataclass class RoutedFrame: """路由后的帧""" app_id: AppType frame: ImageFrame window_meta: WindowMeta route_reason: str = "" @dataclass class OcrLine: """OCR识别的单行文本""" text: str score: float bbox_px: BBox @dataclass class OcrRawResult: """OCR原始结果""" lines: list[OcrLine] engine: str = "rapidocr" latency_ms: float = 0 det_time_ms: float = 0 # 检测耗时 rec_time_ms: float = 0 # 识别耗时 cls_time_ms: float = 0 # 方向分类耗时 model_version: str = "1.0" device: str = "cpu" ================================================ FILE: lifetrace/jobs/proactive_ocr/ocr_engine.py ================================================ """ OCR引擎封装模块 基于 RapidOCR (ONNX Runtime) 的轻量级OCR实现 """ import re import time import numpy as np from lifetrace.util.logging_config import get_logger from .models import BBox, OcrLine, OcrRawResult logger = get_logger() try: from rapidocr_onnxruntime import RapidOCR RAPIDOCR_AVAILABLE = True except ImportError: RapidOCR = None RAPIDOCR_AVAILABLE = False logger.error("rapidocr-onnxruntime not available") try: import cv2 CV2_AVAILABLE = True except ImportError: cv2 = None CV2_AVAILABLE = False logger.warning("opencv-python not available, image resizing disabled") class OcrEngine: """OCR引擎封装类""" def __init__( self, det_limit_side_len: int = 640, det_limit_type: str = "max", rec_batch_num: int = 1, use_gpu: bool = False, resize_max_side: int = 0, # 预缩放最大边长,0表示不缩放 ): """ 初始化OCR引擎 Args: det_limit_side_len: 检测输入图像的边长限制,减小可降低内存占用 det_limit_type: 边长限制类型,"max"限制最大边,"min"限制最小边 rec_batch_num: 识别批次大小,减小可降低内存峰值 use_gpu: 是否使用GPU(需要安装CUDA版本onnxruntime) resize_max_side: 输入图像预缩放的最大边长,0表示不缩放 """ if not RAPIDOCR_AVAILABLE: raise ImportError( "rapidocr-onnxruntime not available. Install with: uv add rapidocr-onnxruntime" ) if RapidOCR is None: raise ImportError("RapidOCR backend is not available") # 配置参数 init_params = { "det_limit_side_len": det_limit_side_len, "det_limit_type": det_limit_type, "rec_batch_num": rec_batch_num, } # GPU配置 if use_gpu: init_params["use_cuda"] = True self.engine = RapidOCR(**init_params) self.det_limit_side_len = det_limit_side_len self.det_limit_type = det_limit_type self.rec_batch_num = rec_batch_num self.resize_max_side = resize_max_side def _resize_image(self, image: np.ndarray, max_side: int) -> tuple: """ 等比例缩小图像 Returns: (缩放后图像, 缩放比例) """ if not CV2_AVAILABLE: logger.warning("OpenCV not available, skipping image resize") return image, 1.0 if cv2 is None: logger.warning("OpenCV not available, skipping image resize") return image, 1.0 cv2_local = cv2 h, w = image.shape[:2] max_dim = max(h, w) if max_dim <= max_side: return image, 1.0 scale = max_side / max_dim new_w = int(w * scale) new_h = int(h * scale) resized = cv2_local.resize(image, (new_w, new_h), interpolation=cv2_local.INTER_AREA) return resized, scale def ocr(self, image: np.ndarray) -> OcrRawResult: """ 对图像执行OCR识别 Args: image: 输入图像,numpy数组,RGB格式 Returns: OcrRawResult: OCR识别结果 """ start_time = time.time() # 预缩放图像 scale = 1.0 if self.resize_max_side > 0: image, scale = self._resize_image(image, self.resize_max_side) # 执行OCR result, elapse = self.engine(image) latency_ms = (time.time() - start_time) * 1000 # 解析 elapse 时间 det_time_ms = 0.0 rec_time_ms = 0.0 cls_time_ms = 0.0 if elapse: # elapse 可能是字符串或字典 if isinstance(elapse, str): # 解析字符串格式 det_match = re.search(r"det[:\s]+(\d+\.?\d*)s?", elapse) rec_match = re.search(r"rec[:\s]+(\d+\.?\d*)s?", elapse) cls_match = re.search(r"cls[:\s]+(\d+\.?\d*)s?", elapse) if det_match: det_time_ms = float(det_match.group(1)) * 1000 if rec_match: rec_time_ms = float(rec_match.group(1)) * 1000 if cls_match: cls_time_ms = float(cls_match.group(1)) * 1000 elif isinstance(elapse, dict): det_time_ms = elapse.get("det", 0) * 1000 rec_time_ms = elapse.get("rec", 0) * 1000 cls_time_ms = elapse.get("cls", 0) * 1000 # 解析结果 lines = [] if result: for item in result: # item格式: [bbox, text, score] # bbox格式: [[x1,y1], [x2,y1], [x2,y2], [x1,y2]] bbox_points = item[0] text = item[1] score = item[2] # 转换bbox为BBox格式(考虑缩放) x_coords = [float(p[0]) for p in bbox_points] y_coords = [float(p[1]) for p in bbox_points] x_min = int(min(x_coords) / scale) y_min = int(min(y_coords) / scale) x_max = int(max(x_coords) / scale) y_max = int(max(y_coords) / scale) bbox = BBox( x=x_min, y=y_min, width=x_max - x_min, height=y_max - y_min, ) lines.append(OcrLine(text=text, score=float(score), bbox_px=bbox)) return OcrRawResult( lines=lines, engine="rapidocr-onnxruntime", latency_ms=latency_ms, det_time_ms=det_time_ms, rec_time_ms=rec_time_ms, cls_time_ms=cls_time_ms, model_version="1.4.4", device="cpu", ) def ocr_simple(self, image: np.ndarray) -> list[tuple[str, float]]: """ 简化版OCR,只返回文本和置信度 Args: image: 输入图像 Returns: [(text, score), ...] 文本和置信度列表 """ result = self.ocr(image) return [(line.text, line.score) for line in result.lines] # 单例实例 _engine_state: dict[str, OcrEngine | None] = {"instance": None} def get_ocr_engine( det_limit_side_len: int = 640, det_limit_type: str = "max", rec_batch_num: int = 1, resize_max_side: int = 0, ) -> OcrEngine: """获取OCR引擎单例""" instance = _engine_state["instance"] if instance is None: instance = OcrEngine( det_limit_side_len=det_limit_side_len, det_limit_type=det_limit_type, rec_batch_num=rec_batch_num, resize_max_side=resize_max_side, ) _engine_state["instance"] = instance return instance ================================================ FILE: lifetrace/jobs/proactive_ocr/priors/__init__.py ================================================ """Application priors for ROI extraction""" from .registry import get_prior, list_priors, register_prior __all__ = ["get_prior", "list_priors", "register_prior"] ================================================ FILE: lifetrace/jobs/proactive_ocr/priors/base.py ================================================ """ 先验配置基类 定义应用先验的通用接口 """ from abc import ABC, abstractmethod from dataclasses import dataclass import numpy as np @dataclass class ThemeConfig: """主题配置""" name: str # 主题名称: "dark" / "light" chat_bg_color: tuple[int, int, int] # 聊天区域背景色 RGB color_tolerance: int = 5 # 颜色容差 @dataclass class ROIResult: """ROI 提取结果""" image: np.ndarray # 裁切后的图像 x: int # 左边界 x 坐标 y: int # 上边界 y 坐标 width: int # 宽度 height: int # 高度 theme: str # 检测到的主题 class AppPrior(ABC): """应用先验基类""" @property @abstractmethod def app_name(self) -> str: """应用名称""" pass @property @abstractmethod def themes(self) -> list[ThemeConfig]: """支持的主题列表""" pass def detect_theme(self, image: np.ndarray) -> ThemeConfig | None: """ 检测当前图像的主题 Args: image: 窗口截图 (RGB) Returns: 检测到的主题配置,未匹配返回 None """ h, w = image.shape[:2] # 在底部区域采样检测主题 sample_y = min(h - 100, h - 1) sample_x = w - 50 # 右下角通常是纯背景 if sample_y < 0 or sample_x < 0: return self.themes[0] if self.themes else None # 取采样点颜色 pixel = image[sample_y, sample_x].astype(np.float32) # 匹配主题 for theme in self.themes: target = np.array(theme.chat_bg_color, dtype=np.float32) if np.all(np.abs(pixel - target) <= theme.color_tolerance): return theme # 默认返回第一个主题 return self.themes[0] if self.themes else None @abstractmethod def extract_chat_roi(self, image: np.ndarray) -> ROIResult: """ 提取聊天区域 ROI Args: image: 完整窗口截图 (RGB) Returns: ROI 提取结果 """ pass def _find_bg_left_edge( self, image: np.ndarray, bg_color: tuple[int, int, int], tolerance: int, sample_heights: list[int], ) -> int | None: """ 找到背景色区域的左边界 Args: image: 图像 bg_color: 目标背景色 tolerance: 颜色容差 sample_heights: 采样高度列表 Returns: 左边界 x 坐标 """ h, _ = image.shape[:2] target = np.array(bg_color, dtype=np.float32) # 过滤有效采样高度 valid_heights = [y for y in sample_heights if 0 < y < h] if not valid_heights: valid_heights = [h // 2] left_edges = [] for y in valid_heights: row = image[y, :, :] left_x = self._scan_row_left_edge(row, target, tolerance) if left_x is not None: left_edges.append(left_x) return min(left_edges) if left_edges else None def _scan_row_left_edge( self, row: np.ndarray, target_color: np.ndarray, tolerance: int ) -> int | None: """从右向左扫描一行,找到目标颜色区域的左边界""" w = row.shape[0] in_target_region = False last_target_x = None for x in range(w - 1, -1, -1): pixel = row[x].astype(np.float32) is_target = np.all(np.abs(pixel - target_color) <= tolerance) if is_target: in_target_region = True last_target_x = x elif in_target_region: return last_target_x if in_target_region: return 0 return None ================================================ FILE: lifetrace/jobs/proactive_ocr/priors/feishu.py ================================================ """ 飞书先验配置 """ import numpy as np from .base import AppPrior, ROIResult, ThemeConfig class FeishuPrior(AppPrior): """飞书应用先验""" @property def app_name(self) -> str: return "feishu" @property def themes(self) -> list[ThemeConfig]: return [ ThemeConfig( name="light", chat_bg_color=(255, 255, 255), color_tolerance=10, ), ThemeConfig( name="dark", chat_bg_color=(30, 30, 30), # 估计值,需要根据实际调整 color_tolerance=10, ), ] def extract_chat_roi(self, image: np.ndarray) -> ROIResult: """ 提取飞书聊天区域 飞书布局类似微信:左侧列表 + 右侧聊天 """ h, w = image.shape[:2] # 1. 先检测当前主题 theme = self.detect_theme(image) theme_name = theme.name if theme else "unknown" # 2. 只用当前主题的背景色检测 ROI split_x = None if theme: sample_heights = [h - 80, h - 120, h - 160] split_x = self._find_bg_left_edge( image, bg_color=theme.chat_bg_color, tolerance=theme.color_tolerance, sample_heights=sample_heights, ) # 兜底 if split_x is None: split_x = int(w * 0.35) chat_region = image[:, split_x:, :] return ROIResult( image=chat_region, x=split_x, y=0, width=w - split_x, height=h, theme=theme_name, ) ================================================ FILE: lifetrace/jobs/proactive_ocr/priors/registry.py ================================================ """ 先验注册表 管理所有应用的先验配置 """ from ..models import AppType from .base import AppPrior from .feishu import FeishuPrior from .wechat import WeChatPrior # 全局先验注册表 _prior_registry: dict[AppType, AppPrior] = {} def _init_default_priors(): """初始化默认先验""" register_prior(AppType.WECHAT, WeChatPrior()) register_prior(AppType.FEISHU, FeishuPrior()) def register_prior(app_type: AppType, prior: AppPrior): """ 注册应用先验 Args: app_type: 应用类型 prior: 先验配置实例 """ _prior_registry[app_type] = prior def get_prior(app_type: AppType) -> AppPrior | None: """ 获取应用先验 Args: app_type: 应用类型 Returns: 先验配置实例,未注册返回 None """ # 懒加载初始化 if not _prior_registry: _init_default_priors() return _prior_registry.get(app_type) def list_priors() -> dict[AppType, AppPrior]: """列出所有已注册的先验""" if not _prior_registry: _init_default_priors() return _prior_registry.copy() ================================================ FILE: lifetrace/jobs/proactive_ocr/priors/wechat.py ================================================ """ 微信先验配置 """ import numpy as np from .base import AppPrior, ROIResult, ThemeConfig class WeChatPrior(AppPrior): """微信应用先验""" @property def app_name(self) -> str: return "wechat" @property def themes(self) -> list[ThemeConfig]: return [ ThemeConfig( name="dark", chat_bg_color=(25, 25, 25), color_tolerance=5, ), ThemeConfig( name="light", chat_bg_color=(237, 237, 237), color_tolerance=5, ), ] def extract_chat_roi(self, image: np.ndarray) -> ROIResult: """ 提取微信聊天区域 微信布局:左侧联系人列表 + 右侧聊天区域 聊天区域背景色:深色(25,25,25) 或 亮色(237,237,237) """ h, w = image.shape[:2] # 1. 先检测当前主题(在右下角采样) theme = self.detect_theme(image) theme_name = theme.name if theme else "unknown" # 2. 只用当前主题的背景色检测 ROI split_x = None if theme: sample_heights = [h - 80, h - 120, h - 160] split_x = self._find_bg_left_edge( image, bg_color=theme.chat_bg_color, tolerance=theme.color_tolerance, sample_heights=sample_heights, ) # 兜底:使用固定比例 if split_x is None: split_x = int(w * 0.35) # 裁切 chat_region = image[:, split_x:, :] return ROIResult( image=chat_region, x=split_x, y=0, width=w - split_x, height=h, theme=theme_name, ) ================================================ FILE: lifetrace/jobs/proactive_ocr/roi.py ================================================ """ ROI (Region of Interest) 提取模块 使用应用先验配置裁切感兴趣的区域 """ from functools import lru_cache import numpy as np from .models import AppType, BBox from .priors import get_prior from .priors.base import ROIResult class ROIExtractor: """ROI 提取器 - 使用先验配置""" def extract_chat_region(self, image: np.ndarray, app_type: AppType) -> tuple[np.ndarray, BBox]: """ 提取聊天区域 Args: image: 完整窗口图像 (RGB) app_type: 应用类型 Returns: (裁切后的图像, 裁切区域的BBox) """ # 获取应用先验 prior = get_prior(app_type) if prior is None: # 无先验配置,返回原图 h, w = image.shape[:2] return image, BBox(x=0, y=0, width=w, height=h) # 使用先验提取 ROI(每次都会动态检测主题) result = prior.extract_chat_roi(image) bbox = BBox( x=result.x, y=result.y, width=result.width, height=result.height, ) return result.image, bbox def extract_with_details(self, image: np.ndarray, app_type: AppType) -> ROIResult | None: """ 提取 ROI 并返回详细信息(包括检测到的主题) Args: image: 完整窗口图像 (RGB) app_type: 应用类型 Returns: ROI 提取结果,包含主题信息 """ prior = get_prior(app_type) if prior is None: return None return prior.extract_chat_roi(image) # 单例实例 @lru_cache(maxsize=1) def get_roi_extractor() -> ROIExtractor: """获取 ROI 提取器单例""" return ROIExtractor() ================================================ FILE: lifetrace/jobs/proactive_ocr/router.py ================================================ """ 应用路由模块 识别窗口是否为微信/飞书 """ from functools import lru_cache from .models import AppType, FrameEvent, RoutedFrame, WindowMeta # 微信相关进程名和窗口标题关键词(跨平台) WECHAT_PROCESS_NAMES = [ # Windows "weixin.exe", # 微信主进程 (新版) "wechat.exe", # 微信主进程 (旧版) "wechatappex.exe", # 微信小程序 "wechatbrowser.exe", # 微信内置浏览器 # macOS "wechat", # macOS 应用名 "微信", # macOS 可能返回中文名 # Linux "wechat", "electronic-wechat", ] WECHAT_TITLE_KEYWORDS = [ "微信", "wechat", ] # 飞书相关进程名和窗口标题关键词(跨平台) FEISHU_PROCESS_NAMES = [ # Windows "feishu.exe", "lark.exe", "bytedance feishu", # macOS "feishu", # macOS 应用名 "lark", # macOS 应用名 "飞书", # macOS 可能返回中文名 # Linux "feishu", "lark", # Electron (需要结合标题确认) "electron", ] FEISHU_TITLE_KEYWORDS = [ "飞书", "feishu", "lark", ] class AppRouter: """应用路由器""" def __init__(self): """初始化路由器""" self.wechat_processes = [p.lower() for p in WECHAT_PROCESS_NAMES] self.wechat_titles = [t.lower() for t in WECHAT_TITLE_KEYWORDS] self.feishu_processes = [p.lower() for p in FEISHU_PROCESS_NAMES] self.feishu_titles = [t.lower() for t in FEISHU_TITLE_KEYWORDS] def identify_app(self, window: WindowMeta) -> tuple[AppType, str]: # noqa: C901 """ 识别窗口对应的应用 Args: window: 窗口元数据 Returns: (应用类型, 识别原因) """ process_name = window.process_name.lower() title = window.title.lower() # 优先通过进程名识别(更准确) for proc in self.wechat_processes: if proc in process_name: return AppType.WECHAT, f"process_match:{proc}" for proc in self.feishu_processes: if proc in process_name: # 飞书的Electron进程需要进一步通过标题确认 if proc == "electron": for keyword in self.feishu_titles: if keyword in title: return AppType.FEISHU, f"process_electron+title:{keyword}" else: return AppType.FEISHU, f"process_match:{proc}" # 通过标题识别(兜底) for keyword in self.wechat_titles: if keyword in title: return AppType.WECHAT, f"title_match:{keyword}" for keyword in self.feishu_titles: if keyword in title: return AppType.FEISHU, f"title_match:{keyword}" return AppType.UNKNOWN, "no_match" def route(self, frame_event: FrameEvent) -> RoutedFrame | None: """ 路由帧事件 Args: frame_event: 帧事件 Returns: 路由后的帧,如果是未知应用返回None """ app_type, reason = self.identify_app(frame_event.window_meta) # 未知应用直接丢弃 if app_type == AppType.UNKNOWN: return None return RoutedFrame( app_id=app_type, frame=frame_event.frame, window_meta=frame_event.window_meta, route_reason=reason, ) def is_target_window(self, window: WindowMeta) -> bool: """ 检查窗口是否为目标应用 Args: window: 窗口元数据 Returns: 是否为目标应用 """ app_type, _ = self.identify_app(window) return app_type != AppType.UNKNOWN def filter_target_windows(self, windows: list[WindowMeta]) -> list[tuple[WindowMeta, AppType]]: """ 筛选目标应用窗口 Args: windows: 窗口列表 Returns: (窗口, 应用类型) 列表 """ results = [] for window in windows: app_type, _ = self.identify_app(window) if app_type != AppType.UNKNOWN: results.append((window, app_type)) return results # 单例实例 @lru_cache(maxsize=1) def get_router() -> AppRouter: """获取路由器单例""" return AppRouter() ================================================ FILE: lifetrace/jobs/proactive_ocr/service.py ================================================ """ Proactive OCR Service 主动检测并处理 WeChat/Feishu 窗口的 OCR 服务 """ import hashlib import sys import threading import time from functools import lru_cache from typing import Any from PIL import Image from lifetrace.llm.todo_extraction_service import todo_extraction_service from lifetrace.storage import ocr_mgr, screenshot_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_screenshots_dir from lifetrace.util.settings import settings from lifetrace.util.utils import ensure_dir from .capture import get_capture from .models import AppType from .ocr_engine import get_ocr_engine from .roi import get_roi_extractor from .router import get_router logger = get_logger() class ProactiveOCRService: """Proactive OCR 服务""" def __init__(self): self.is_running = False self._monitor_thread: threading.Thread | None = None self._stop_event = threading.Event() # 从配置读取参数 self.interval = settings.get("jobs.proactive_ocr.interval", 1.0) self.use_roi = settings.get("jobs.proactive_ocr.use_roi", True) self.resize_max_side = settings.get("jobs.proactive_ocr.resize_max_side", 800) self.det_limit_side_len = settings.get("jobs.proactive_ocr.det_limit_side_len", 640) self.min_confidence = settings.get("jobs.proactive_ocr.min_confidence", 0.8) # 初始化组件 self.router = get_router() self.capture = get_capture(fps=1.0 / self.interval) self.roi_extractor = get_roi_extractor() self.ocr_engine = get_ocr_engine( det_limit_side_len=self.det_limit_side_len, resize_max_side=self.resize_max_side, ) # 统计信息 self.stats = { "total_captures": 0, "successful_ocrs": 0, "failed_captures": 0, "last_capture_time": None, } logger.info( f"ProactiveOCR: Service initialized (interval={self.interval}s, " f"use_roi={self.use_roi}, resize_max_side={self.resize_max_side})" ) def start(self): """启动监控服务""" if self.is_running: logger.warning("ProactiveOCR: Service is already running") return self.is_running = True self._stop_event.clear() self._monitor_thread = threading.Thread( target=self._monitor_loop, daemon=True, name="ProactiveOCRMonitor" ) self._monitor_thread.start() logger.info( f"ProactiveOCR: Service started (interval={self.interval}s, " f"apps=['wechat', 'feishu'], platform={sys.platform})" ) def stop(self): """停止监控服务""" if not self.is_running: return self.is_running = False self._stop_event.set() if self._monitor_thread and self._monitor_thread.is_alive(): self._monitor_thread.join(timeout=5.0) logger.info("ProactiveOCR: Service stopped") def _monitor_loop(self): """监控循环""" while self.is_running and not self._stop_event.is_set(): try: self.run_once() except Exception as e: logger.error(f"ProactiveOCR: Error in monitor loop: {e}", exc_info=True) # 等待间隔时间 self._stop_event.wait(self.interval) def run_once(self) -> dict[str, Any] | None: """ 执行一次检测和处理 Returns: 处理结果字典,如果未检测到目标窗口返回 None """ # 获取前台窗口(跨平台) window = self.capture.get_foreground_window() if not window: return None # 检查是否为目标应用 app_type, _reason = self.router.identify_app(window) if app_type == AppType.UNKNOWN: return None logger.info( f"ProactiveOCR: Detected foreground window: hwnd={window.hwnd}, " f'app={app_type.value}, title="{window.title[:50]}"' ) # 检查窗口是否最小化 if window.is_minimized: logger.debug("ProactiveOCR: Window is minimized, skipping capture") return None logger.debug(f"ProactiveOCR: Window size: {window.rect.width}x{window.rect.height}") # 捕获窗口截图 timings = {} t0 = time.perf_counter() frame = self.capture.capture_window(window) timings["capture"] = (time.perf_counter() - t0) * 1000 if frame is None: logger.error("ProactiveOCR: Capture window failed") self.stats["failed_captures"] += 1 return None logger.info( f"ProactiveOCR: Capture completed in {timings['capture']:.0f}ms " f"({frame.width}x{frame.height})" ) # ROI 裁切 image_to_ocr = frame.data theme = None if self.use_roi: t0 = time.perf_counter() roi_result = self.roi_extractor.extract_with_details(frame.data, app_type) timings["roi"] = (time.perf_counter() - t0) * 1000 if roi_result: image_to_ocr = roi_result.image theme = roi_result.theme logger.info( f"ProactiveOCR: ROI extracted - theme={theme}, " f"region={roi_result.width}x{roi_result.height} " f"(from x={roi_result.x}), time={timings['roi']:.1f}ms" ) # 执行 OCR 识别 logger.debug("ProactiveOCR: Starting OCR recognition...") t0 = time.perf_counter() ocr_result = self.ocr_engine.ocr(image_to_ocr) timings["ocr_total"] = (time.perf_counter() - t0) * 1000 logger.info( f"ProactiveOCR: OCR completed in {timings['ocr_total']:.0f}ms " f"(det={ocr_result.det_time_ms:.0f}ms, rec={ocr_result.rec_time_ms:.0f}ms)" ) # 过滤低置信度结果 valid_lines = [line for line in ocr_result.lines if line.score >= self.min_confidence] logger.info( f"ProactiveOCR: Found {len(valid_lines)} text blocks " f"(confidence >={self.min_confidence})" ) if len(valid_lines) > 0: # 提取文本内容 text_content = "\n".join([line.text for line in valid_lines]) logger.debug(f"ProactiveOCR: Text preview: {text_content[:100]}...") # 保存截图和 OCR 结果到数据库 screenshot_id = self._save_to_database( frame, window, app_type, text_content, ocr_result, valid_lines ) if screenshot_id: self.stats["successful_ocrs"] += 1 logger.info( f"ProactiveOCR: Saved screenshot_id={screenshot_id}, " f"ocr_result with {len(valid_lines)} lines" ) self.stats["total_captures"] += 1 self.stats["last_capture_time"] = time.time() total_time = sum(timings.values()) logger.debug(f"ProactiveOCR: Total time: {total_time:.0f}ms") return { "app_type": app_type.value, "window_title": window.title, "text_lines": len(valid_lines), "timings": timings, } def _save_to_database( self, frame, window, app_type: AppType, text_content: str, ocr_result, valid_lines, ) -> int | None: """保存截图和 OCR 结果到数据库""" try: # 保存图像文件 screenshots_dir = get_screenshots_dir() ensure_dir(str(screenshots_dir)) # 生成文件名 timestamp = time.strftime("%Y%m%d_%H%M%S") filename = f"proactive_{app_type.value}_{timestamp}_{frame.capture_id}.png" file_path = str(screenshots_dir / filename) # 保存图像(PIL Image) img = Image.fromarray(frame.data) img.save(file_path) # 计算文件哈希 with open(file_path, "rb") as f: file_hash = hashlib.md5(f.read(), usedforsecurity=False).hexdigest() # 添加截图记录 screenshot_id = screenshot_mgr.add_screenshot( file_path=file_path, file_hash=file_hash, width=frame.width, height=frame.height, metadata={ "screen_id": 0, "app_name": app_type.value, "window_title": window.title, "proactive_ocr": True, "hwnd": window.hwnd, "pid": window.pid, }, ) if not screenshot_id: logger.error("ProactiveOCR: Failed to save screenshot to database") return None # 计算平均置信度 avg_confidence = ( sum(line.score for line in valid_lines) / len(valid_lines) if valid_lines else 0.0 ) # 添加 OCR 结果 ocr_result_id = ocr_mgr.add_ocr_result( screenshot_id=screenshot_id, text_content=text_content, confidence=avg_confidence, language="ch", processing_time=ocr_result.latency_ms / 1000.0, ) if ocr_result_id: logger.debug(f"ProactiveOCR: Saved OCR result_id={ocr_result_id}") # 可选:自动触发基于 OCR 文本的待办提取 try: auto_extract = settings.get( "jobs.proactive_ocr.params.auto_extract_todos", False ) min_text_length = settings.get( "jobs.proactive_ocr.params.min_text_length", 10, ) if auto_extract and len((text_content or "").strip()) >= min_text_length: logger.info( "ProactiveOCR: auto_extract_todos 开启,开始基于 OCR 文本提取待办" ) # 我们仅调用提取逻辑,不在此处直接写 todo,结果由上层或日志查看 extraction_result = todo_extraction_service.extract_todos_from_ocr_text( ocr_result_id=ocr_result_id, text_content=text_content, app_name=app_type.value, window_title=window.title, ) if extraction_result.get("skipped"): logger.info( "ProactiveOCR: OCR 文本待办提取已跳过 - " f"reason={extraction_result.get('reason')}, " f"ocr_result_id={extraction_result.get('ocr_result_id')}" ) else: todos_count = len(extraction_result.get("todos") or []) error_message = extraction_result.get("error_message") created_count = extraction_result.get("created_count") if error_message: logger.warning( "ProactiveOCR: OCR 文本待办提取完成但存在错误 - " f"error={error_message}, " f"ocr_result_id={extraction_result.get('ocr_result_id')}, " f"todos_count={todos_count}, " f"created_count={created_count}" ) else: logger.info( "ProactiveOCR: OCR 文本待办提取完成 - " f"ocr_result_id={extraction_result.get('ocr_result_id')}, " f"todos_count={todos_count}, " f"created_count={created_count}" ) except Exception as e: logger.error(f"ProactiveOCR: 自动待办提取失败(已忽略): {e}", exc_info=True) return screenshot_id return None except Exception as e: logger.error(f"ProactiveOCR: Failed to save to database: {e}", exc_info=True) return None def get_status(self) -> dict[str, Any]: """获取服务状态""" return { "is_running": self.is_running, "interval": self.interval, "use_roi": self.use_roi, "platform": sys.platform, "stats": self.stats.copy(), } # 单例实例 @lru_cache(maxsize=1) def get_proactive_ocr_service() -> ProactiveOCRService: """获取 Proactive OCR 服务单例""" return ProactiveOCRService() ================================================ FILE: lifetrace/jobs/recorder.py ================================================ """ 屏幕录制器 - 负责截图和相关处理 """ import argparse import os import time from datetime import datetime from functools import lru_cache import mss from PIL import Image from lifetrace.storage import event_mgr, screenshot_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_screenshots_dir from lifetrace.util.settings import settings from lifetrace.util.utils import ensure_dir, get_active_window_info, get_active_window_screen from .recorder_blacklist import get_blacklist_reason, log_blacklist_config from .recorder_capture import ( ScreenshotCapture, extract_screen_id_from_path, get_unprocessed_files, process_screenshot_event, should_detect_todos, trigger_todo_detection_async, ) from .recorder_config import UNKNOWN_APP, UNKNOWN_WINDOW, with_timeout logger = get_logger() class ScreenRecorder: """屏幕录制器""" def __init__(self): self.screenshots_dir = str(get_screenshots_dir()) self.interval = settings.get("jobs.recorder.interval") self.screens = self._get_screen_list() # 超时配置 self.file_io_timeout = settings.get("jobs.recorder.params.file_io_timeout") self.db_timeout = settings.get("jobs.recorder.params.db_timeout") self.window_info_timeout = settings.get("jobs.recorder.params.window_info_timeout") # 初始化截图捕获器 self.capture = ScreenshotCapture( screenshots_dir=self.screenshots_dir, file_io_timeout=self.file_io_timeout, db_timeout=self.db_timeout, deduplicate=settings.get("jobs.recorder.params.deduplicate"), hash_threshold=settings.get("jobs.recorder.params.hash_threshold"), ) # 初始化截图目录 ensure_dir(self.screenshots_dir) logger.info( f"超时配置 - 文件I/O: {self.file_io_timeout}s, " f"数据库: {self.db_timeout}s, " f"窗口信息: {self.window_info_timeout}s" ) logger.info(f"屏幕录制器初始化完成,监控屏幕: {self.screens}") # 打印黑名单配置信息 log_blacklist_config() # 启动时扫描未处理的文件 self._scan_unprocessed_files() def _get_window_info(self) -> tuple[str, str]: """获取当前活动窗口信息""" @with_timeout(timeout_seconds=self.window_info_timeout, operation_name="获取窗口信息") def _do_get_window_info(): return get_active_window_info() try: result = _do_get_window_info() if result is not None: app_name, window_title = result app_name = app_name or UNKNOWN_APP window_title = window_title or UNKNOWN_WINDOW return (app_name, window_title) return (UNKNOWN_APP, UNKNOWN_WINDOW) except Exception as e: logger.error(f"获取窗口信息失败: {e}") return (UNKNOWN_APP, UNKNOWN_WINDOW) def _get_screen_list(self) -> list[int]: """获取要截图的屏幕列表""" screens_config = settings.get("jobs.recorder.params.screens") logger.debug(f"屏幕配置: {screens_config}") with mss.mss() as sct: monitor_count = len(sct.monitors) - 1 if screens_config == "all": return list(range(1, monitor_count + 1)) elif isinstance(screens_config, list): return [s for s in screens_config if 1 <= s <= monitor_count] else: return [1] if monitor_count > 0 else [] def _capture_screen( self, screen_id: int, app_name: str | None = None, window_title: str | None = None, ) -> tuple[str | None, str]: """截取指定屏幕 Returns: (file_path, status) - file_path为截图路径,status为状态: 'success', 'skipped', 'failed' """ try: screenshot, file_path, timestamp = self.capture.grab_and_prepare_screenshot(screen_id) if not screenshot: return None, "failed" # 优化:先从内存计算图像哈希,避免不必要的磁盘I/O image_hash = self.capture.calculate_image_hash_from_memory(screenshot) if not image_hash: filename = os.path.basename(file_path) logger.error(f"[窗口 {screen_id}] 计算图像哈希失败,跳过: {filename}") return None, "failed" # 检查是否重复 if self.capture.is_duplicate(screen_id, image_hash): filename = os.path.basename(file_path) logger.debug(f"[窗口 {screen_id}] 检测到重复截图,跳过保存: {filename}") return None, "skipped" # 更新哈希记录并保存截图 self.capture.last_hashes[screen_id] = image_hash if not self.capture.save_screenshot(screenshot, file_path): filename = os.path.basename(file_path) logger.error(f"[窗口 {screen_id}] 保存截图失败: {filename}") return None, "failed" # 获取窗口信息和保存到数据库 app_name, window_title = self._ensure_window_info(app_name, window_title) self._save_screenshot_metadata(file_path, screen_id, app_name, window_title, timestamp) return file_path, "success" except Exception as e: logger.error(f"[窗口 {screen_id}] 截图失败: {e}") return None, "failed" def _ensure_window_info( self, app_name: str | None, window_title: str | None, ) -> tuple[str, str]: """确保有窗口信息,如果没有则获取""" if app_name is None or window_title is None: return self._get_window_info() return app_name, window_title def _save_screenshot_metadata( self, file_path: str, screen_id: int, app_name: str, window_title: str, timestamp: datetime ): """保存截图的元数据到数据库""" filename = os.path.basename(file_path) width, height = self.capture.get_image_size(file_path) file_hash = self.capture.calculate_file_hash(file_path) if not file_hash: logger.warning(f"[窗口 {screen_id}] 计算文件哈希失败,使用空值: {filename}") file_hash = "" screenshot_id = self.capture.save_to_database( file_path, file_hash, width, height, screen_id, app_name, window_title ) if screenshot_id: logger.debug(f"[窗口 {screen_id}] 截图记录已保存到数据库: {screenshot_id}") process_screenshot_event(screenshot_id, app_name, window_title, timestamp) if should_detect_todos(app_name): trigger_todo_detection_async(screenshot_id, app_name) else: logger.warning(f"[窗口 {screen_id}] 数据库保存失败,但文件已保存: {filename}") file_size = os.path.getsize(file_path) file_size_kb = file_size / 1024 logger.info(f"[窗口 {screen_id}] 截图保存: {filename} ({file_size_kb:.2f} KB) - {app_name}") def _close_active_event_on_blacklist(self): """当应用进入黑名单时关闭活跃事件""" try: event_mgr.close_active_event() logger.info("已关闭上一个活跃事件") except Exception as e: logger.error(f"关闭活跃事件失败: {e}") def capture_all_screens(self) -> list[str]: """只截取活跃窗口所在的屏幕""" captured_files = [] app_name, window_title = self._get_window_info() active_screen_id = get_active_window_screen() if active_screen_id is None: logger.warning("无法获取活跃窗口所在的屏幕,跳过截图") return captured_files if active_screen_id not in self.screens: logger.info(f"⏭️ 活跃窗口在屏幕 {active_screen_id},但该屏幕未在配置中启用,跳过截图") return captured_files blacklist_reason = get_blacklist_reason(app_name, window_title) is_blacklisted = bool(blacklist_reason) if is_blacklisted: logger.info(f"⏭️ {blacklist_reason}(跳过截图)") self._close_active_event_on_blacklist() return captured_files logger.info( f"📸 准备截图 - 屏幕: {active_screen_id}, 应用: {app_name}, 窗口: {window_title}" ) file_path, status = self._capture_screen(active_screen_id, app_name, window_title) if file_path: captured_files.append(file_path) if status == "success": logger.info(f"截图成功 - 屏幕: {active_screen_id}") elif status == "skipped": logger.info(f"截图跳过 - 屏幕: {active_screen_id}") elif status == "failed": logger.warning(f"截图失败 - 屏幕: {active_screen_id}") return captured_files def execute_capture(self): """执行一次截图任务(用于调度器调用) Returns: 捕获的文件列表 """ try: captured_files = self.capture_all_screens() if captured_files: logger.info(f"✅ 本次截取了 {len(captured_files)} 张截图") else: logger.info("⏭️ 本次未截取截图(窗口被跳过或重复)") return captured_files except Exception as e: logger.error(f"执行截图任务失败: {e}") return [] def start_recording(self): """开始录制(传统模式,独立运行)""" logger.info("开始屏幕录制...") try: while True: start_time = time.time() captured_files = self.capture_all_screens() if captured_files: logger.debug(f"本次截取了 {len(captured_files)} 张截图") elapsed = time.time() - start_time sleep_time = max(0, self.interval - elapsed) if sleep_time > 0: time.sleep(sleep_time) else: logger.warning(f"截图处理时间 ({elapsed:.2f}s) 超过间隔时间 ({self.interval}s)") except KeyboardInterrupt: logger.error("收到停止信号,结束录制") self._print_final_stats() except Exception as e: logger.error(f"录制过程中发生错误: {e}") self._print_final_stats() raise finally: pass def _process_single_file(self, file_path: str) -> bool: """处理单个未处理的截图文件,返回是否成功""" if not os.path.exists(file_path): return False file_stats = os.stat(file_path) if file_stats.st_size == 0: logger.warning(f"文件为空,跳过: {file_path}") return False try: with Image.open(file_path) as img: width, height = img.size except Exception as e: logger.error(f"无法处理图像文件 {file_path}: {e}") return False screen_id = extract_screen_id_from_path(file_path) file_hash = self.capture.calculate_file_hash(file_path) if not file_hash: filename = os.path.basename(file_path) logger.warning(f"[窗口 {screen_id}] 计算文件哈希失败,使用空值: {filename}") file_hash = "" app_name, window_title = self._get_window_info() screenshot_id = screenshot_mgr.add_screenshot( file_path=file_path, file_hash=file_hash, width=width, height=height, metadata={ "screen_id": screen_id, "app_name": app_name, "window_title": window_title, }, ) if screenshot_id: filename = os.path.basename(file_path) logger.debug(f"[窗口 {screen_id}] 已处理未处理文件: {filename} (ID: {screenshot_id})") return True logger.warning(f"[窗口 {screen_id}] 添加截图记录失败: {file_path}") return False def _scan_unprocessed_files(self): """扫描并处理未处理的截图文件""" if not os.path.exists(self.screenshots_dir): logger.info("截图目录不存在,跳过扫描") return logger.info(f"扫描现有截图文件: {self.screenshots_dir}") unprocessed_files = get_unprocessed_files(self.screenshots_dir) if not unprocessed_files: logger.info("未发现未处理的截图文件") return logger.info(f"发现 {len(unprocessed_files)} 个未处理文件,开始处理...") processed_count = 0 for file_path in unprocessed_files: try: if self._process_single_file(file_path): processed_count += 1 except Exception as e: logger.error(f"处理文件失败 {file_path}: {e}") logger.info( f"未处理文件扫描完成,成功处理 {processed_count}/{len(unprocessed_files)} 个文件" ) def _print_final_stats(self): """输出最终统计信息""" logger.info("录制会话结束") # 全局录制器实例(用于调度器任务) @lru_cache(maxsize=1) def get_recorder_instance() -> ScreenRecorder: """获取全局录制器实例 Returns: ScreenRecorder 实例 """ return ScreenRecorder() def execute_capture_task(): """执行截图任务(供调度器调用的可序列化函数) 这是一个模块级别的函数,可以被 APScheduler 序列化到数据库中 """ try: logger.info("🔄 开始执行录制器任务") recorder = get_recorder_instance() captured_files = recorder.execute_capture() return len(captured_files) except Exception as e: logger.error(f"执行录制器任务失败: {e}", exc_info=True) return 0 if __name__ == "__main__": parser = argparse.ArgumentParser(description="LifeTrace Screen Recorder") parser.add_argument("--config", help="配置文件路径") parser.add_argument("--interval", type=int, help="截图间隔(秒)") parser.add_argument("--screens", help='要截图的屏幕,用逗号分隔或使用"all"') parser.add_argument("--debug", action="store_true", help="启用调试日志") args = parser.parse_args() if args.interval: settings.set("jobs.recorder.interval", args.interval) if args.screens: if args.screens.lower() == "all": settings.set("jobs.recorder.params.screens", "all") else: screens = [int(s.strip()) for s in args.screens.split(",")] settings.set("jobs.recorder.params.screens", screens) recorder = ScreenRecorder() recorder.start_recording() ================================================ FILE: lifetrace/jobs/recorder_blacklist.py ================================================ """ 屏幕录制器黑名单处理模块 包含黑名单检测和LifeTrace窗口识别逻辑 """ from lifetrace.util.app_utils import expand_blacklist_apps from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from .recorder_config import ( BROWSER_APPS, LIFETRACE_WINDOW_PATTERNS_REGEX, LIFETRACE_WINDOW_PATTERNS_STR, PYTHON_APPS, ) logger = get_logger() def check_window_title_patterns(window_title: str) -> bool: """检查窗口标题是否匹配LifeTrace模式(支持动态端口)""" window_title_lower = window_title.lower() # 检查字符串包含模式 if any(pattern in window_title_lower for pattern in LIFETRACE_WINDOW_PATTERNS_STR): return True # 检查正则表达式模式(用于端口范围匹配) return any(pattern.search(window_title_lower) for pattern in LIFETRACE_WINDOW_PATTERNS_REGEX) def is_browser_or_python_app(app_name_lower: str) -> bool: """检查是否为浏览器或Python应用""" return any(browser in app_name_lower for browser in BROWSER_APPS + PYTHON_APPS) def is_lifetrace_window(app_name: str, window_title: str) -> bool: """检查是否为LifeTrace相关窗口""" if not app_name and not window_title: return False # 直接检查窗口标题是否包含LifeTrace模式 if window_title and check_window_title_patterns(window_title): return True # 检查应用名:如果是浏览器或Python应用,需要进一步检查窗口标题 if app_name: app_name_lower = app_name.lower() if is_browser_or_python_app(app_name_lower) and window_title: return check_window_title_patterns(window_title) return False def get_app_blacklist_reason(app_name: str) -> str: """获取应用名在黑名单中的原因 Returns: 如果在黑名单中,返回跳过原因;否则返回空字符串 """ if not app_name: return "" blacklist_apps = settings.get("jobs.recorder.params.blacklist.apps") expanded_blacklist_apps = expand_blacklist_apps(blacklist_apps) if not expanded_blacklist_apps: return "" app_name_lower = app_name.lower() for blacklist_app in expanded_blacklist_apps: if blacklist_app.lower() == app_name_lower or blacklist_app.lower() in app_name_lower: return f"🚫 [黑名单过滤] 应用 '{app_name}' 匹配黑名单项 '{blacklist_app}'" return "" def get_window_blacklist_reason(window_title: str) -> str: """获取窗口标题在黑名单中的原因 Returns: 如果在黑名单中,返回跳过原因;否则返回空字符串 """ if not window_title: return "" blacklist_windows = settings.get("jobs.recorder.params.blacklist.windows") if not blacklist_windows: return "" window_title_lower = window_title.lower() for blacklist_window in blacklist_windows: if ( blacklist_window.lower() == window_title_lower or blacklist_window.lower() in window_title_lower ): return f"🚫 [黑名单过滤] 窗口 '{window_title}' 匹配黑名单项 '{blacklist_window}'" return "" def get_blacklist_reason(app_name: str, window_title: str) -> str: """获取应用被列入黑名单的原因 Returns: 如果在黑名单中,返回跳过原因;否则返回空字符串 """ # 首先检查是否启用自动排除LifeTrace自身窗口 auto_exclude_self = settings.get("jobs.recorder.params.auto_exclude_self") if auto_exclude_self and is_lifetrace_window(app_name, window_title): return ( f"🏠 [自动排除] 检测到 LifeTrace 自身窗口 - 应用: '{app_name}', 窗口: '{window_title}'" ) # 检查黑名单功能是否启用 blacklist_enabled = settings.get("jobs.recorder.params.blacklist.enabled") if not blacklist_enabled: return "" # 检查应用名是否在黑名单中 app_reason = get_app_blacklist_reason(app_name) if app_reason: return app_reason # 检查窗口标题是否在黑名单中 window_reason = get_window_blacklist_reason(window_title) if window_reason: return window_reason return "" def log_blacklist_config(): """打印当前黑名单配置""" blacklist_enabled = settings.get("jobs.recorder.params.blacklist.enabled") blacklist_apps = settings.get("jobs.recorder.params.blacklist.apps") blacklist_windows = settings.get("jobs.recorder.params.blacklist.windows") logger.info("=" * 60) logger.info(f"📋 黑名单配置状态: {'✅ 已启用' if blacklist_enabled else '❌ 已禁用'}") if blacklist_enabled: if blacklist_apps: expanded_apps = expand_blacklist_apps(blacklist_apps) logger.info(f"🚫 黑名单应用: {blacklist_apps}") logger.info(f" 扩展后的进程名: {expanded_apps}") else: logger.info("🚫 黑名单应用: 无") if blacklist_windows: logger.info(f"🚫 黑名单窗口: {blacklist_windows}") else: logger.info("🚫 黑名单窗口: 无") else: logger.info(" (黑名单功能未启用,所有应用都会被截图)") logger.info("=" * 60) ================================================ FILE: lifetrace/jobs/recorder_capture.py ================================================ """ 屏幕录制器截图捕获模块 包含截图捕获、保存和数据库操作 """ import hashlib import importlib import os import threading from datetime import datetime from pathlib import Path from typing import Any import imagehash import mss from mss import tools as mss_tools from PIL import Image from lifetrace.storage import event_mgr, screenshot_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now from lifetrace.util.utils import get_screenshot_filename from .recorder_config import UNKNOWN_APP, UNKNOWN_WINDOW, with_timeout logger = get_logger() class ScreenshotCapture: """截图捕获类,处理截图的捕获、保存和数据库操作""" def __init__( self, screenshots_dir: str, file_io_timeout: float, db_timeout: float, deduplicate: bool, hash_threshold: int, ): self.screenshots_dir = screenshots_dir self.file_io_timeout = file_io_timeout self.db_timeout = db_timeout self.deduplicate = deduplicate self.hash_threshold = hash_threshold self.last_hashes = {} def save_screenshot(self, screenshot, file_path: str) -> bool: """保存截图到文件""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="保存截图文件") def _do_save(): mss_tools.to_png(screenshot.rgb, screenshot.size, output=file_path) return True try: result = _do_save() return result if result is not None else False except Exception as e: logger.error(f"保存截图失败 {file_path}: {e}") return False def get_image_size(self, file_path: str) -> tuple: """获取图像尺寸""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="读取图像尺寸") def _do_get_size(): with Image.open(file_path) as img: return img.size try: result = _do_get_size() return result if result is not None else (0, 0) except Exception as e: logger.error(f"读取图像尺寸失败 {file_path}: {e}") return (0, 0) def calculate_file_hash(self, file_path: str) -> str: """计算文件MD5哈希""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="计算文件哈希") def _do_calculate_hash(): with open(file_path, "rb") as f: return hashlib.md5(f.read(), usedforsecurity=False).hexdigest() try: result = _do_calculate_hash() return result if result is not None else "" except Exception as e: logger.error(f"计算文件哈希失败 {file_path}: {e}") return "" def calculate_image_hash(self, image_path: str) -> str: """计算图像感知哈希值""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="计算图像哈希") def _do_calculate_hash(): with Image.open(image_path) as img: return str(imagehash.phash(img)) try: result = _do_calculate_hash() return result if result is not None else "" except Exception as e: logger.error(f"计算图像哈希失败 {image_path}: {e}") return "" def calculate_image_hash_from_memory(self, screenshot) -> str: """直接从内存中的截图计算图像感知哈希值""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="从内存计算图像哈希") def _do_calculate_hash(): img = Image.frombytes("RGB", screenshot.size, screenshot.rgb) return str(imagehash.phash(img)) try: result = _do_calculate_hash() return result if result is not None else "" except Exception as e: logger.error(f"从内存计算图像哈希失败: {e}") return "" def is_duplicate(self, screen_id: int, image_hash: str) -> bool: """检查是否为重复图像""" if not self.deduplicate: return False if screen_id not in self.last_hashes: return False last_hash = self.last_hashes[screen_id] try: current = imagehash.hex_to_hash(image_hash) previous = imagehash.hex_to_hash(last_hash) distance = current - previous is_dup = distance <= self.hash_threshold if is_dup: logger.info(f"[窗口 {screen_id}] 跳过重复截图") return is_dup except Exception as e: logger.error(f"比较图像哈希失败: {e}") return False def save_to_database( self, file_path: str, file_hash: str, width: int, height: int, screen_id: int, app_name: str, window_title: str, ) -> int | None: """保存截图信息到数据库""" @with_timeout(timeout_seconds=self.db_timeout, operation_name="数据库操作") def _do_save_to_db(): screenshot_id = screenshot_mgr.add_screenshot( file_path=file_path, file_hash=file_hash, width=width, height=height, metadata={ "screen_id": screen_id, "app_name": app_name or UNKNOWN_APP, "window_title": window_title or UNKNOWN_WINDOW, "event_id": None, }, ) return screenshot_id try: result = _do_save_to_db() return result except Exception as e: logger.error(f"保存截图记录到数据库失败: {e}") return None def grab_and_prepare_screenshot(self, screen_id: int) -> tuple[Any | None, str, datetime]: """抓取屏幕并准备截图文件路径""" with mss.mss() as sct: if screen_id >= len(sct.monitors): logger.warning(f"[窗口 {screen_id}] 屏幕ID不存在") return None, "", get_utc_now() monitor = sct.monitors[screen_id] screenshot = sct.grab(monitor) timestamp = get_utc_now() filename = get_screenshot_filename(screen_id, timestamp) file_path = os.path.join(self.screenshots_dir, filename) return screenshot, file_path, timestamp def process_screenshot_event( screenshot_id: int, app_name: str, window_title: str, timestamp: datetime, ): """处理截图事件:将截图关联到事件 Args: screenshot_id: 截图ID app_name: 应用名称 window_title: 窗口标题 timestamp: 截图时间 """ try: event_id = event_mgr.get_or_create_event( app_name=app_name, window_title=window_title, timestamp=timestamp, ) if event_id: success = event_mgr.add_screenshot_to_event(screenshot_id, event_id) if success: logger.info( f"📎 截图 {screenshot_id} 已添加到事件 {event_id} [{app_name} - {window_title}]" ) else: logger.warning(f"⚠️ 截图 {screenshot_id} 添加到事件失败") else: logger.warning(f"⚠️ 获取或创建事件失败,截图ID: {screenshot_id}") except Exception as e: logger.error(f"处理截图事件失败: {e}", exc_info=True) def get_unprocessed_files(screenshots_dir: str) -> list[str]: """获取所有未处理的截图文件列表""" screenshot_files = [] for file_path in Path(screenshots_dir).glob("*.png"): if file_path.is_file(): screenshot_files.append(str(file_path)) unprocessed_files = [] for file_path in screenshot_files: screenshot = screenshot_mgr.get_screenshot_by_path(file_path) if not screenshot: unprocessed_files.append(file_path) return unprocessed_files def extract_screen_id_from_path(file_path: str) -> int: """从文件名提取屏幕ID""" min_filename_parts = 2 try: filename = os.path.basename(file_path) if filename.startswith("screen_"): parts = filename.split("_") if len(parts) >= min_filename_parts: return int(parts[1]) except (ValueError, IndexError): pass return 0 def should_detect_todos(app_name: str) -> bool: """判断是否需要触发待办检测 Args: app_name: 应用名称 Returns: 是否需要检测 """ try: enabled = settings.get("jobs.auto_todo_detection.enabled") if not enabled: logger.debug(f"自动待办检测已禁用,跳过应用: {app_name}") return False except KeyError: logger.debug("自动待办检测配置项不存在,跳过检测") return False if not app_name: return False auto_module = importlib.import_module("lifetrace.llm.auto_todo_detection_service") whitelist_apps = auto_module.get_whitelist_apps() app_name_lower = app_name.lower() is_whitelist = any(whitelist_app.lower() in app_name_lower for whitelist_app in whitelist_apps) if is_whitelist: logger.info(f"🔍 检测到白名单应用: {app_name},将触发自动待办检测") else: logger.debug(f"应用 {app_name} 不在白名单中,跳过自动待办检测") return is_whitelist def trigger_todo_detection_async(screenshot_id: int, _app_name: str): """异步触发待办检测 Args: screenshot_id: 截图ID app_name: 应用名称 """ def _detect_todos(): try: auto_module = importlib.import_module("lifetrace.llm.auto_todo_detection_service") auto_todo_detection_service_class = auto_module.AutoTodoDetectionService service = auto_todo_detection_service_class() result = service.detect_and_create_todos_from_screenshot(screenshot_id) logger.info( f"截图 {screenshot_id} 待办检测完成,创建 {result.get('created_count', 0)} 个draft待办" ) except Exception as e: logger.error( f"截图 {screenshot_id} 待办检测失败: {e}", exc_info=True, ) thread = threading.Thread(target=_detect_todos, daemon=True) thread.start() ================================================ FILE: lifetrace/jobs/recorder_config.py ================================================ """ 屏幕录制器配置模块 包含常量、模式匹配和装饰器 """ import re from concurrent.futures import Future, ThreadPoolExecutor from functools import wraps from lifetrace.util.logging_config import get_logger logger = get_logger() # 常量定义 UNKNOWN_APP = "未知应用" UNKNOWN_WINDOW = "未知窗口" DEFAULT_SCREEN_ID = 0 # 用于应用使用记录的默认屏幕ID # LifeTrace窗口识别模式(支持字符串包含匹配和正则表达式) LIFETRACE_WINDOW_PATTERNS_STR = [ "lifetrace", "lifetrace - intelligent life recording system", "lifetrace desktop", "lifetrace 智能生活记录系统", "lifetrace 桌面版", "lifetrace frontend", "lifetrace web interface", "freetodo", # Electron 应用名 ] # 端口范围模式(支持 8000-8099 和 3000-3099 动态端口) LIFETRACE_WINDOW_PATTERNS_REGEX = [ re.compile(r"localhost:80\d{2}"), # 匹配 localhost:8000-8099 re.compile(r"127\.0\.0\.1:80\d{2}"), # 匹配 127.0.0.1:8000-8099 re.compile(r"localhost:30\d{2}"), # 匹配 localhost:3000-3099 re.compile(r"127\.0\.0\.1:30\d{2}"), # 匹配 127.0.0.1:3000-3099 ] BROWSER_APPS = ["chrome", "msedge", "firefox", "electron"] PYTHON_APPS = ["python", "pythonw"] def with_timeout(timeout_seconds: float = 5.0, operation_name: str = "操作"): """超时装饰器 - 使用 Future 实现更清晰的超时控制""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): executor = ThreadPoolExecutor(max_workers=1) future: Future = executor.submit(func, *args, **kwargs) try: result = future.result(timeout=timeout_seconds) return result except TimeoutError: logger.warning(f"{operation_name}超时 ({timeout_seconds}秒),操作可能仍在后台执行") return None except Exception as e: logger.error(f"{operation_name}执行失败: {e}") raise finally: executor.shutdown(wait=False) return wrapper return decorator ================================================ FILE: lifetrace/jobs/scheduler.py ================================================ """ APScheduler 调度器管理模块,用于管理 LifeTrace 的定时任务 """ import os from functools import lru_cache from apscheduler.events import ( EVENT_JOB_ADDED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_REMOVED, ) from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_scheduler_database_path from lifetrace.util.settings import settings logger = get_logger() class SchedulerManager: """APScheduler 调度器管理器""" def __init__(self): """初始化调度器管理器""" self.scheduler: BackgroundScheduler | None = None self._setup_scheduler() def _setup_scheduler(self): """设置 APScheduler 调度器""" # 从配置获取调度器数据库路径 scheduler_db_path = str(get_scheduler_database_path()) # 确保数据库目录存在 os.makedirs(os.path.dirname(scheduler_db_path), exist_ok=True) # 配置作业存储(持久化到 SQLite) jobstores = {"default": SQLAlchemyJobStore(url=f"sqlite:///{scheduler_db_path}")} # 配置执行器(线程池) max_workers = settings.get("scheduler.max_workers") executors = {"default": ThreadPoolExecutor(max_workers=max_workers)} # 调度器配置 job_defaults = { "coalesce": settings.get("scheduler.coalesce"), # 合并错过的任务 "max_instances": settings.get("scheduler.max_instances"), # 同一任务同时只能有一个实例 "misfire_grace_time": settings.get( "scheduler.misfire_grace_time" ), # 错过触发时间的容忍度(秒) } # 创建调度器 timezone = settings.get("scheduler.timezone") self.scheduler = BackgroundScheduler( jobstores=jobstores, executors=executors, job_defaults=job_defaults, timezone=timezone, # 从配置读取时区 ) # 添加事件监听器 self.scheduler.add_listener(self._job_executed_listener, EVENT_JOB_EXECUTED) self.scheduler.add_listener(self._job_error_listener, EVENT_JOB_ERROR) self.scheduler.add_listener(self._job_added_listener, EVENT_JOB_ADDED) self.scheduler.add_listener(self._job_removed_listener, EVENT_JOB_REMOVED) logger.info(f"调度器已初始化,作业数据库: {scheduler_db_path}") def _job_executed_listener(self, event): """任务执行成功的监听器""" logger.debug( f"任务执行成功: {event.job_id}, " f"返回值: {event.retval if hasattr(event, 'retval') else 'None'}" ) def _job_error_listener(self, event): """任务执行错误的监听器""" logger.error( f"任务执行失败: {event.job_id}, 异常: {event.exception}, traceback: {event.traceback}" ) def _job_added_listener(self, event): """任务添加的监听器""" logger.info(f"任务已添加: {event.job_id}") def _job_removed_listener(self, event): """任务移除的监听器""" logger.info(f"任务已移除: {event.job_id}") def start(self): """启动调度器""" if self.scheduler and not self.scheduler.running: self.scheduler.start() logger.info("调度器已启动") else: logger.warning("调度器已经在运行中") def shutdown(self, wait: bool = True): """关闭调度器 Args: wait: 是否等待所有任务执行完毕 """ if self.scheduler and self.scheduler.running: self.scheduler.shutdown(wait=wait) logger.error("调度器已关闭") else: logger.warning("调度器未运行") def add_interval_job( self, func, job_id: str, name: str | None = None, seconds: int | None = None, minutes: int | None = None, hours: int | None = None, replace_existing: bool = True, **kwargs, ): """添加间隔型任务 Args: func: 要执行的函数 job_id: 任务ID name: 任务名称(显示用) seconds: 间隔秒数 minutes: 间隔分钟数 hours: 间隔小时数 replace_existing: 如果任务已存在是否替换 **kwargs: 传递给函数的参数 """ if not self.scheduler: logger.error("调度器未初始化") return None try: # 构建间隔参数 interval_kwargs = {} if seconds is not None: interval_kwargs["seconds"] = seconds if minutes is not None: interval_kwargs["minutes"] = minutes if hours is not None: interval_kwargs["hours"] = hours if not interval_kwargs: logger.error("必须指定至少一个时间间隔参数") return None job = self.scheduler.add_job( func, trigger="interval", id=job_id, name=name, replace_existing=replace_existing, kwargs=kwargs, **interval_kwargs, ) logger.info( f"添加间隔任务: {job_id} ({name}), 间隔: {interval_kwargs}, 下次运行: {job.next_run_time}" ) return job except Exception as e: logger.error(f"添加任务失败: {e}") return None def add_date_job( self, func, job_id: str, run_date, name: str | None = None, replace_existing: bool = True, **kwargs, ): """添加一次性任务(指定时间触发)""" if not self.scheduler: logger.error("调度器未初始化") return None try: job = self.scheduler.add_job( func, trigger="date", id=job_id, name=name, run_date=run_date, replace_existing=replace_existing, kwargs=kwargs, ) logger.info(f"添加一次性任务: {job_id} ({name}), 触发时间: {job.next_run_time}") return job except Exception as e: logger.error(f"添加一次性任务失败: {e}") return None def remove_job(self, job_id: str): """移除任务 Args: job_id: 任务ID """ if not self.scheduler: logger.error("调度器未初始化") return False try: self.scheduler.remove_job(job_id) logger.info(f"任务已移除: {job_id}") return True except Exception as e: logger.error(f"移除任务失败: {e}") return False def pause_job(self, job_id: str): """暂停任务 Args: job_id: 任务ID """ if not self.scheduler: logger.error("调度器未初始化") return False try: self.scheduler.pause_job(job_id) logger.warning(f"任务已暂停: {job_id}") return True except Exception as e: logger.error(f"暂停任务失败: {e}") return False def resume_job(self, job_id: str): """恢复任务 Args: job_id: 任务ID """ if not self.scheduler: logger.error("调度器未初始化") return False try: self.scheduler.resume_job(job_id) logger.warning(f"任务已恢复: {job_id}") return True except Exception as e: logger.error(f"恢复任务失败: {e}") return False def get_job(self, job_id: str): """获取任务信息 Args: job_id: 任务ID Returns: 任务对象或None """ if not self.scheduler: logger.error("调度器未初始化") return None return self.scheduler.get_job(job_id) def get_all_jobs(self): """获取所有任务 Returns: 任务列表 """ if not self.scheduler: logger.error("调度器未初始化") return [] return self.scheduler.get_jobs() def modify_job_interval( self, job_id: str, seconds: int | None = None, minutes: int | None = None, hours: int | None = None, ): """修改任务的执行间隔 Args: job_id: 任务ID seconds: 新的间隔秒数 minutes: 新的间隔分钟数 hours: 新的间隔小时数 """ if not self.scheduler: logger.error("调度器未初始化") return False try: # 构建间隔参数 interval_kwargs = {} if seconds is not None: interval_kwargs["seconds"] = seconds if minutes is not None: interval_kwargs["minutes"] = minutes if hours is not None: interval_kwargs["hours"] = hours if not interval_kwargs: logger.error("必须指定至少一个时间间隔参数") return False # 创建新的触发器 new_trigger = IntervalTrigger(**interval_kwargs) self.scheduler.modify_job(job_id, trigger=new_trigger) logger.info(f"任务间隔已修改: {job_id}, 新间隔: {interval_kwargs}") return True except Exception as e: logger.error(f"修改任务间隔失败: {e}") return False def pause_all_jobs(self): """暂停所有任务 Returns: 暂停成功的任务数量 """ if not self.scheduler: logger.error("调度器未初始化") return 0 try: jobs = self.get_all_jobs() paused_count = 0 for job in jobs: # 只暂停未暂停的任务 if job.next_run_time is not None: try: self.scheduler.pause_job(job.id) paused_count += 1 except Exception as e: logger.error(f"暂停任务 {job.id} 失败: {e}") logger.warning(f"已暂停 {paused_count} 个任务") return paused_count except Exception as e: logger.error(f"批量暂停任务失败: {e}") return 0 def resume_all_jobs(self): """恢复所有任务 Returns: 恢复成功的任务数量 """ if not self.scheduler: logger.error("调度器未初始化") return 0 try: jobs = self.get_all_jobs() resumed_count = 0 for job in jobs: # 只恢复已暂停的任务 if job.next_run_time is None: try: self.scheduler.resume_job(job.id) resumed_count += 1 except Exception as e: logger.error(f"恢复任务 {job.id} 失败: {e}") logger.warning(f"已恢复 {resumed_count} 个任务") return resumed_count except Exception as e: logger.error(f"批量恢复任务失败: {e}") return 0 # 全局调度器实例 @lru_cache(maxsize=1) def get_scheduler_manager() -> SchedulerManager: """获取全局调度器管理器实例 Returns: SchedulerManager 实例 """ return SchedulerManager() ================================================ FILE: lifetrace/jobs/todo_recorder.py ================================================ """ Todo 专用屏幕录制器 - 仅录制白名单应用,用于自动待办检测 与通用屏幕录制器(recorder.py)完全独立: - 用户可以只开启 Todo 专用录制,而不开启通用录制 - 两者可以同时运行,互不影响 - 复用截图核心逻辑,但维护独立的运行状态 """ import hashlib import importlib import os import threading from concurrent.futures import Future, ThreadPoolExecutor from functools import lru_cache, wraps import imagehash import mss from mss import tools as mss_tools from PIL import Image from lifetrace.llm.auto_todo_detection_service import get_whitelist_apps from lifetrace.storage import screenshot_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_screenshots_dir from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now from lifetrace.util.utils import ( ensure_dir, get_active_window_info, get_active_window_screen, get_screenshot_filename, ) logger = get_logger() # 常量定义 UNKNOWN_APP = "未知应用" UNKNOWN_WINDOW = "未知窗口" def with_timeout(timeout_seconds: float = 5.0, operation_name: str = "操作"): """超时装饰器 - 使用线程池实现超时控制""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): executor = ThreadPoolExecutor(max_workers=1) future: Future = executor.submit(func, *args, **kwargs) try: result = future.result(timeout=timeout_seconds) return result except TimeoutError: logger.warning(f"{operation_name}超时 ({timeout_seconds}秒)") return None except Exception as e: logger.error(f"{operation_name}执行失败: {e}") raise finally: executor.shutdown(wait=False) return wrapper return decorator class TodoScreenRecorder: """Todo 专用屏幕录制器 仅在白名单应用激活时截图,截图后直接触发自动待办检测。 与通用录制器完全独立,不依赖其运行状态。 """ def __init__(self): """初始化 Todo 专用录制器""" self.screenshots_dir = str(get_screenshots_dir()) self.interval = settings.get("jobs.todo_recorder.interval", 5) self.deduplicate = settings.get("jobs.todo_recorder.params.deduplicate", True) self.hash_threshold = settings.get("jobs.todo_recorder.params.hash_threshold", 5) # 超时配置 self.file_io_timeout = settings.get("jobs.todo_recorder.params.file_io_timeout", 15) self.db_timeout = settings.get("jobs.todo_recorder.params.db_timeout", 20) self.window_info_timeout = settings.get("jobs.todo_recorder.params.window_info_timeout", 5) # 初始化截图目录 ensure_dir(self.screenshots_dir) # 独立的上一张截图哈希值(用于去重,与通用录制器独立) self.last_hash: str | None = None logger.info(f"[Todo录制器] 初始化完成,间隔: {self.interval}秒,去重: {self.deduplicate}") def _get_window_info(self) -> tuple[str, str]: """获取当前活动窗口信息""" @with_timeout(timeout_seconds=self.window_info_timeout, operation_name="获取窗口信息") def _do_get_window_info(): return get_active_window_info() try: result = _do_get_window_info() if result is not None: app_name, window_title = result app_name = app_name or UNKNOWN_APP window_title = window_title or UNKNOWN_WINDOW return (app_name, window_title) return (UNKNOWN_APP, UNKNOWN_WINDOW) except Exception as e: logger.error(f"[Todo录制器] 获取窗口信息失败: {e}") return (UNKNOWN_APP, UNKNOWN_WINDOW) def _is_whitelist_app(self, app_name: str) -> bool: """检查当前应用是否在白名单中 Args: app_name: 应用名称 Returns: 是否为白名单应用 """ if not app_name or app_name == UNKNOWN_APP: return False whitelist_apps = get_whitelist_apps() app_name_lower = app_name.lower() return any(whitelist_app.lower() in app_name_lower for whitelist_app in whitelist_apps) def _calculate_image_hash_from_memory(self, screenshot) -> str: """从内存中的截图计算图像感知哈希值""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="计算图像哈希") def _do_calculate_hash(): img = Image.frombytes("RGB", screenshot.size, screenshot.rgb) return str(imagehash.phash(img)) try: result = _do_calculate_hash() return result if result is not None else "" except Exception as e: logger.error(f"[Todo录制器] 计算图像哈希失败: {e}") return "" def _is_duplicate(self, image_hash: str) -> bool: """检查是否为重复图像""" if not self.deduplicate or not self.last_hash: return False try: current = imagehash.hex_to_hash(image_hash) previous = imagehash.hex_to_hash(self.last_hash) distance = current - previous is_duplicate = distance <= self.hash_threshold if is_duplicate: logger.debug("[Todo录制器] 检测到重复截图,跳过") return is_duplicate except Exception as e: logger.error(f"[Todo录制器] 比较图像哈希失败: {e}") return False def _save_screenshot(self, screenshot, file_path: str) -> bool: """保存截图到文件""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="保存截图文件") def _do_save(): mss_tools.to_png(screenshot.rgb, screenshot.size, output=file_path) return True try: result = _do_save() return result if result is not None else False except Exception as e: logger.error(f"[Todo录制器] 保存截图失败 {file_path}: {e}") return False def _get_image_size(self, file_path: str) -> tuple: """获取图像尺寸""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="读取图像尺寸") def _do_get_size(): with Image.open(file_path) as img: return img.size try: result = _do_get_size() return result if result is not None else (0, 0) except Exception as e: logger.error(f"[Todo录制器] 读取图像尺寸失败 {file_path}: {e}") return (0, 0) def _calculate_file_hash(self, file_path: str) -> str: """计算文件MD5哈希""" @with_timeout(timeout_seconds=self.file_io_timeout, operation_name="计算文件哈希") def _do_calculate_hash(): with open(file_path, "rb") as f: return hashlib.md5(f.read(), usedforsecurity=False).hexdigest() try: result = _do_calculate_hash() return result if result is not None else "" except Exception as e: logger.error(f"[Todo录制器] 计算文件哈希失败 {file_path}: {e}") return "" def _save_to_database( self, file_path: str, file_hash: str, width: int, height: int, screen_id: int, app_name: str, window_title: str, ) -> int | None: """保存截图信息到数据库""" @with_timeout(timeout_seconds=self.db_timeout, operation_name="数据库操作") def _do_save_to_db(): screenshot_id = screenshot_mgr.add_screenshot( file_path=file_path, file_hash=file_hash, width=width, height=height, metadata={ "screen_id": screen_id, "app_name": app_name or UNKNOWN_APP, "window_title": window_title or UNKNOWN_WINDOW, "source": "todo_recorder", # 标记来源为 Todo 专用录制器 "event_id": None, }, ) return screenshot_id try: result = _do_save_to_db() return result except Exception as e: logger.error(f"[Todo录制器] 保存截图记录到数据库失败: {e}") return None def _trigger_todo_detection(self, screenshot_id: int, app_name: str): """触发自动待办检测 Args: screenshot_id: 截图ID app_name: 应用名称 """ _ = app_name def _detect_todos(): try: auto_module = importlib.import_module("lifetrace.llm.auto_todo_detection_service") auto_todo_detection_service_class = auto_module.AutoTodoDetectionService service = auto_todo_detection_service_class() result = service.detect_and_create_todos_from_screenshot(screenshot_id) logger.info( f"[Todo录制器] 截图 {screenshot_id} 待办检测完成," f"创建 {result.get('created_count', 0)} 个 draft 待办" ) except Exception as e: logger.error( f"[Todo录制器] 截图 {screenshot_id} 待办检测失败: {e}", exc_info=True, ) # 使用后台线程异步执行,避免阻塞截图流程 thread = threading.Thread(target=_detect_todos, daemon=True) thread.start() def _check_whitelist_and_screen(self, app_name: str) -> tuple[int, str, str] | None: """检查白名单应用和屏幕 Returns: (screen_id, app_name, window_title) 或 None(如果检查失败) """ _, window_title = self._get_window_info() if not self._is_whitelist_app(app_name): logger.debug(f"[Todo录制器] 当前应用 '{app_name}' 不在白名单中,跳过截图") return None active_screen_id = get_active_window_screen() if active_screen_id is None: logger.warning("[Todo录制器] 无法获取活跃窗口所在的屏幕,跳过截图") return None logger.info( f"[Todo录制器] 📸 检测到白名单应用: {app_name},准备截图 - 屏幕: {active_screen_id}" ) return (active_screen_id, app_name, window_title) def _capture_and_save( self, active_screen_id: int, app_name: str, window_title: str, ) -> str | None: """执行截图并保存 Returns: 截图文件路径,如果失败则返回 None """ with mss.mss() as sct: if active_screen_id >= len(sct.monitors): logger.warning(f"[Todo录制器] 屏幕ID {active_screen_id} 不存在") return None monitor = sct.monitors[active_screen_id] screenshot = sct.grab(monitor) timestamp = get_utc_now() filename = f"todo_{get_screenshot_filename(active_screen_id, timestamp)}" file_path = os.path.join(self.screenshots_dir, filename) # 计算图像哈希(用于去重) image_hash = self._calculate_image_hash_from_memory(screenshot) if not image_hash: logger.error("[Todo录制器] 计算图像哈希失败,跳过") return None # 检查是否重复 if self._is_duplicate(image_hash): return None # 更新哈希记录并保存 self.last_hash = image_hash if not self._save_screenshot(screenshot, file_path): logger.error(f"[Todo录制器] 保存截图失败: {filename}") return None # 保存元数据并触发检测 self._save_metadata_and_trigger( file_path, filename, active_screen_id, app_name, window_title ) return file_path def _save_metadata_and_trigger( self, file_path: str, filename: str, screen_id: int, app_name: str, window_title: str, ) -> None: """保存元数据并触发待办检测""" width, height = self._get_image_size(file_path) file_hash = self._calculate_file_hash(file_path) or "" screenshot_id = self._save_to_database( file_path, file_hash, width, height, screen_id, app_name, window_title ) file_size = os.path.getsize(file_path) file_size_kb = file_size / 1024 logger.info(f"[Todo录制器] ✅ 截图保存: {filename} ({file_size_kb:.2f} KB) - {app_name}") if screenshot_id: self._trigger_todo_detection(screenshot_id, app_name) else: logger.warning(f"[Todo录制器] 数据库保存失败,但文件已保存: {filename}") def capture_whitelist_app(self) -> str | None: """截取白名单应用的屏幕 仅在当前活动窗口为白名单应用时才截图。 Returns: 截图文件路径,如果未截图则返回 None """ app_name, window_title = self._get_window_info() check_result = self._check_whitelist_and_screen(app_name) if check_result is None: return None active_screen_id, app_name, window_title = check_result try: return self._capture_and_save(active_screen_id, app_name, window_title) except Exception as e: logger.error(f"[Todo录制器] 截图失败: {e}", exc_info=True) return None def execute_capture(self) -> str | None: """执行一次截图任务(用于调度器调用) Returns: 截图文件路径,如果未截图则返回 None """ try: result = self.capture_whitelist_app() if result: logger.info("[Todo录制器] ✅ 本次截取了白名单应用截图") else: logger.debug("[Todo录制器] ⏭️ 本次未截取截图(非白名单应用或重复)") return result except Exception as e: logger.error(f"[Todo录制器] 执行截图任务失败: {e}", exc_info=True) return None # 全局录制器实例(用于调度器任务) @lru_cache(maxsize=1) def get_todo_recorder_instance() -> TodoScreenRecorder: """获取全局 Todo 录制器实例 Returns: TodoScreenRecorder 实例 """ return TodoScreenRecorder() def execute_todo_capture_task() -> int: """执行 Todo 截图任务(供调度器调用的可序列化函数) 这是一个模块级别的函数,可以被 APScheduler 序列化到数据库中 Returns: 1 如果截图成功,0 如果未截图 """ try: logger.debug("🔄 [Todo录制器] 开始执行截图任务") recorder = get_todo_recorder_instance() result = recorder.execute_capture() return 1 if result else 0 except Exception as e: logger.error(f"[Todo录制器] 执行任务失败: {e}", exc_info=True) return 0 ================================================ FILE: lifetrace/llm/activity_summary_service.py ================================================ """ 活动摘要生成服务 使用LLM为活动(聚合的事件)生成标题和摘要 """ import json from datetime import UTC, datetime from typing import Any from lifetrace.llm.llm_client import LLMClient from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.token_usage_logger import log_token_usage logger = get_logger() # 常量定义 MAX_TITLE_LENGTH = 50 # 活动标题最大长度 RESPONSE_PREVIEW_LENGTH = 500 # 响应预览文本长度 MAX_FALLBACK_TITLES = 3 # 后备方案中最多显示的事件标题数量 class ActivitySummaryService: """活动摘要生成服务""" def __init__(self): """初始化服务""" self.llm_client = LLMClient() def generate_activity_summary( self, events: list[dict[str, Any]], start_time: datetime, end_time: datetime, ) -> dict[str, str] | None: """ 为活动生成摘要 Args: events: 事件列表,每个事件包含 ai_title 和 ai_summary start_time: 活动开始时间 end_time: 活动结束时间 Returns: {'title': str, 'summary': str} 或 None """ try: if not events: logger.warning("事件列表为空,无法生成活动摘要") return None # 如果LLM不可用,使用后备方案 if not self.llm_client.is_available(): logger.warning("LLM客户端不可用,使用后备方案") return self._generate_fallback_summary(events, start_time, end_time) # 准备输入数据,支持时间信息 event_summaries = [] for event in events: title = event.get("ai_title", "") summary = event.get("ai_summary", "") # 支持 start_time 或 time 字段 event_time = event.get("start_time") or event.get("time") if title or summary: event_data = {"title": title, "summary": summary} if event_time: event_data["time"] = event_time event_summaries.append(event_data) if not event_summaries: logger.warning("所有事件都没有AI总结,使用后备方案") return self._generate_fallback_summary(events, start_time, end_time) # 如果有时间信息,按时间排序 if any("time" in e for e in event_summaries): event_summaries.sort( key=lambda x: x.get("time") or datetime.min.replace(tzinfo=UTC) ) # 使用LLM生成总结 result = self._generate_summary_with_llm( event_summaries=event_summaries, start_time=start_time, end_time=end_time, ) # 如果LLM生成成功,返回结果;否则返回fallback return ( result if result else self._generate_fallback_summary(events, start_time, end_time) ) except Exception as e: logger.error(f"生成活动摘要时出错: {e}", exc_info=True) return self._generate_fallback_summary(events, start_time, end_time) def _generate_summary_with_llm( self, event_summaries: list[dict[str, str]], start_time: datetime, end_time: datetime, ) -> dict[str, str] | None: """ 使用LLM生成活动标题和摘要 Args: event_summaries: 事件摘要列表,每个包含 title 和 summary start_time: 活动开始时间 end_time: 活动结束时间 Returns: {'title': str, 'summary': str} 或 None """ try: # 格式化时间 start_str = start_time.strftime("%Y-%m-%d %H:%M:%S") if start_time else "未知" end_str = end_time.strftime("%Y-%m-%d %H:%M:%S") if end_time else "进行中" # 构建事件摘要文本(按时间线格式) events_text = "" has_time_info = any("time" in e for e in event_summaries) for i, event in enumerate(event_summaries, 1): title = event.get("title", "无标题") summary = event.get("summary", "无摘要") if has_time_info and "time" in event: # 如果有时间信息,按时间线格式呈现 event_time = event.get("time") if isinstance(event_time, datetime): time_str = event_time.strftime("%H:%M:%S") else: time_str = str(event_time) events_text += f"{i}. [{time_str}] {title}\n {summary}\n\n" else: # 无时间信息,使用原有格式 events_text += f"{i}. 标题:{title}\n 摘要:{summary}\n\n" # 从配置文件加载提示词 system_prompt = get_prompt("activity_summary", "system_assistant") user_prompt = get_prompt( "activity_summary", "user_prompt", start_time=start_str, end_time=end_str, events_text=events_text, event_count=len(event_summaries), ) # 调用LLM(增加max_tokens以支持结构化摘要) client = self.llm_client._get_client() response = client.chat.completions.create( model=self.llm_client.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0.3, max_tokens=1000, # 增加token限制以支持结构化摘要(500字中文约需1000 tokens) ) # 记录token使用量 if hasattr(response, "usage") and response.usage: log_token_usage( model=self.llm_client.model, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, endpoint="activity_summary", response_type="summary_generation", feature_type="activity_summary", ) # 解析响应 content = (response.choices[0].message.content or "").strip() if content: extracted_content, original_content = self._extract_json_from_response(content) if extracted_content: result = self._parse_llm_response(extracted_content, original_content) if result: return result else: logger.warning(f"提取JSON后内容为空,原始响应: {original_content[:200]}") else: logger.warning("LLM返回空内容,使用后备方案") except Exception as e: logger.error(f"LLM生成活动摘要失败: {e}", exc_info=True) return None def _extract_json_from_response(self, content: str) -> tuple[str, str]: """从LLM响应中提取JSON内容 Returns: (提取的JSON内容, 原始内容) """ original_content = content if "```json" in content: json_start = content.find("```json") + 7 json_end = content.find("```", json_start) content = content[json_start:json_end].strip() elif "```" in content: json_start = content.find("```") + 3 json_end = content.find("```", json_start) content = content[json_start:json_end].strip() return content, original_content def _parse_llm_response(self, content: str, original_content: str) -> dict[str, str] | None: """解析LLM响应为字典 Returns: 解析后的结果,如果失败则返回None """ try: result = json.loads(content) if "title" in result and "summary" in result: title = result["title"][:MAX_TITLE_LENGTH] summary = result["summary"][:1500] # 摘要限制在1500字符(约500-750中文字) return {"title": title, "summary": summary} logger.warning(f"LLM返回格式不正确: {result}") return None except json.JSONDecodeError as e: preview = ( original_content[:RESPONSE_PREVIEW_LENGTH] if len(original_content) > RESPONSE_PREVIEW_LENGTH else original_content ) logger.error(f"解析LLM响应JSON失败: {e}\n原始响应: {preview[:200]}") return None def _generate_fallback_summary( self, events: list[dict[str, Any]], start_time: datetime, end_time: datetime, ) -> dict[str, str]: """ 无LLM时的后备方案 基于事件标题生成简单描述 """ _ = start_time _ = end_time if not events: return {"title": "无活动", "summary": "该时间段内无活动记录"} # 收集所有事件的标题 titles = [] for event in events: title = event.get("ai_title", "") if title: titles.append(title) if not titles: return {"title": "活动记录", "summary": f"包含 {len(events)} 个事件"} # 生成简单标题(取第一个标题或合并) title = titles[0] if len(titles) == 1 else f"{titles[0]}等{len(titles)}项活动" # 生成简单摘要 summary = f"包含 {len(events)} 个事件:" for i, t in enumerate(titles[:MAX_FALLBACK_TITLES], 1): # 最多显示MAX_FALLBACK_TITLES个 summary += f"{i}. {t};" if len(titles) > MAX_FALLBACK_TITLES: summary += f"等共{len(titles)}项" return {"title": title[:MAX_TITLE_LENGTH], "summary": summary} # 全局实例 activity_summary_service = ActivitySummaryService() ================================================ FILE: lifetrace/llm/agent_service.py ================================================ """Agent 服务,管理工具调用工作流""" import json from collections.abc import Generator from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: from openai.types.chat import ChatCompletionMessageParam else: ChatCompletionMessageParam = Any # 导入工具模块以触发工具注册 from lifetrace.llm import tools # noqa: F401 from lifetrace.llm.llm_client import LLMClient from lifetrace.llm.tools.base import ToolResult from lifetrace.llm.tools.registry import ToolRegistry from lifetrace.util.language import get_language_instruction from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt logger = get_logger() class AgentService: """Agent 服务,管理工具调用工作流""" MAX_TOOL_CALLS = 5 # 最大工具调用次数 MAX_ITERATIONS = 10 # 最大迭代次数 def __init__(self): """初始化 Agent 服务""" self.llm_client = LLMClient() # 使用单例模式的工具注册表(工具已在 tools/__init__.py 中注册) self.tool_registry = ToolRegistry() def stream_agent_response( self, user_query: str, todo_context: str | None = None, conversation_history: list[dict] | None = None, lang: str = "zh", ) -> Generator[str]: """ 流式生成 Agent 回答 工作流: 1. 工具选择:LLM 判断是否需要工具 2. 工具执行:执行选中的工具 3. 任务评估:LLM 评估任务是否完成 4. 循环控制:如果未完成,重新进入工具选择 """ tool_call_count = 0 iteration_count = 0 accumulated_context = [] # 构建初始消息 messages = self._build_initial_messages( user_query, todo_context, conversation_history, lang, ) while iteration_count < self.MAX_ITERATIONS: iteration_count += 1 logger.info(f"[Agent] 迭代 {iteration_count}/{self.MAX_ITERATIONS}") # 步骤1: 工具选择 tool_decision = self._decide_tool_usage(messages, tool_call_count) if tool_decision["use_tool"]: # 步骤2: 执行工具 if tool_call_count >= self.MAX_TOOL_CALLS: yield "\n[提示] 已达到最大工具调用次数限制,将基于已有信息生成回答。\n\n" break tool_name = tool_decision["tool_name"] tool_params = tool_decision.get("tool_params", {}) # 构建工具调用标记,包含参数信息(特别是搜索关键词) if tool_name == "web_search" and "query" in tool_params: # 对于 web_search,显示搜索关键词 yield f"\n[使用工具: {tool_name} | 关键词: {tool_params['query']}]\n\n" else: # 其他工具,显示工具名称和参数(如果有) params_str = ", ".join([f"{k}: {v}" for k, v in tool_params.items()]) if params_str: yield f"\n[使用工具: {tool_name} | {params_str}]\n\n" else: yield f"\n[使用工具: {tool_name}]\n\n" tool_result = self._execute_tool(tool_name, tool_params) tool_call_count += 1 # 将工具结果添加到上下文 tool_context = self._format_tool_result(tool_name, tool_result) accumulated_context.append(tool_context) # 更新消息历史 messages.append( { "role": "assistant", "content": f"[工具调用: {tool_name}]", } ) messages.append( { "role": "user", "content": f"[工具结果]\n{tool_context}", } ) # 步骤3: 任务评估 should_continue = self._evaluate_task_completion( user_query, messages, tool_result, ) if not should_continue: logger.info("[Agent] 任务评估:可以生成最终回答") break else: # 不需要工具,直接生成回答 logger.info("[Agent] 不需要工具,直接生成回答") break # 步骤4: 生成最终回答 yield from self._generate_final_response( user_query, messages, accumulated_context, ) def _build_initial_messages( self, user_query: str, todo_context: str | None, conversation_history: list[dict] | None, lang: str = "zh", ) -> list[dict]: """构建初始消息列表""" messages = [] # 系统提示词 system_prompt = get_prompt("agent", "system") if not system_prompt: system_prompt = self._get_default_system_prompt() # 注入语言指令 system_prompt += get_language_instruction(lang) messages.append({"role": "system", "content": system_prompt}) # 添加待办上下文(如果有) if todo_context: messages.append( { "role": "user", "content": f"用户当前的待办事项上下文:\n{todo_context}\n\n", } ) # 添加对话历史(如果有) if conversation_history: messages.extend(conversation_history) # 添加当前用户查询 messages.append({"role": "user", "content": user_query}) return messages def _decide_tool_usage( self, messages: list[dict], tool_call_count: int, ) -> dict[str, Any]: """ 决定是否需要使用工具 Returns: { "use_tool": bool, "tool_name": str | None, "tool_params": dict | None } """ if tool_call_count >= self.MAX_TOOL_CALLS: return {"use_tool": False, "tool_name": None, "tool_params": None} # 获取可用工具列表 available_tools = self.tool_registry.get_available_tools() if not available_tools: return {"use_tool": False, "tool_name": None, "tool_params": None} # 构建工具选择提示词 tools_schema = self.tool_registry.get_tools_schema() tool_selection_prompt = get_prompt( "agent", "tool_selection", tools=json.dumps(tools_schema, ensure_ascii=False, indent=2), user_query=messages[-1]["content"] if messages else "", ) if not tool_selection_prompt: tool_selection_prompt = self._get_default_tool_selection_prompt( tools_schema, ) # 调用 LLM 进行工具选择 try: decision_messages = self._build_tool_decision_messages(messages, tool_selection_prompt) decision = self._call_llm_for_tool_selection(decision_messages) if decision: use_tool = decision.get("use_tool", False) tool_name = decision.get("tool_name") tool_params = decision.get("tool_params", {}) if use_tool and tool_name: logger.info( f"[Agent] 选择工具: {tool_name}, 参数: {tool_params}", ) return { "use_tool": True, "tool_name": tool_name, "tool_params": tool_params, } except Exception as e: logger.error(f"[Agent] 工具选择失败: {e}") return {"use_tool": False, "tool_name": None, "tool_params": None} def _build_tool_decision_messages( self, messages: list[dict], tool_selection_prompt: str ) -> list[dict]: """构建工具选择决策消息,包含完整的上下文但排除工具相关消息""" decision_messages = [{"role": "system", "content": tool_selection_prompt}] # 添加所有非工具相关的消息(保留待办上下文和对话历史) for msg in messages: # 跳过系统提示词(使用新的工具选择提示词) if msg.get("role") == "system": continue content = msg.get("content", "") # 跳过工具调用和工具结果相关的消息 if content.startswith("[工具调用:") or content.startswith("[工具结果]"): continue # 保留待办上下文、对话历史和用户查询 decision_messages.append(msg) return decision_messages def _call_llm_for_tool_selection(self, decision_messages: list[dict]) -> dict[str, Any] | None: """调用 LLM 进行工具选择并解析响应""" client = self.llm_client._get_client() response = client.chat.completions.create( model=self.llm_client.model, messages=cast("list[ChatCompletionMessageParam]", decision_messages), temperature=0.1, # 低温度确保稳定决策 max_tokens=200, ) decision_text = (response.choices[0].message.content or "").strip() # 解析 JSON 响应 try: # 清理可能的 markdown 代码块 clean_text = decision_text.strip() if clean_text.startswith("```json"): clean_text = clean_text[7:] if clean_text.endswith("```"): clean_text = clean_text[:-3] clean_text = clean_text.strip() return json.loads(clean_text) except json.JSONDecodeError: logger.warning( f"[Agent] 工具选择响应解析失败: {decision_text}", ) return None def _execute_tool(self, tool_name: str, tool_params: dict) -> ToolResult: """执行工具""" tool = self.tool_registry.get_tool(tool_name) if not tool: return ToolResult( success=False, content="", error=f"工具 {tool_name} 不存在", ) try: return tool.execute(**tool_params) except Exception as e: logger.error(f"[Agent] 工具执行失败: {e}") return ToolResult( success=False, content="", error=str(e), ) def _format_tool_result(self, tool_name: str, result: ToolResult) -> str: """格式化工具结果""" if not result.success: return f"工具 {tool_name} 执行失败: {result.error}" formatted = f"工具 {tool_name} 执行结果:\n{result.content}" # 如果有来源信息,添加到末尾 if result.metadata and "sources" in result.metadata: sources = result.metadata["sources"] formatted += "\n\nSources:" for idx, source in enumerate(sources, start=1): formatted += f"\n{idx}. {source['title']} ({source['url']})" return formatted def _evaluate_task_completion( self, user_query: str, messages: list[dict], tool_result: ToolResult, ) -> bool: """ 评估任务是否完成 Returns: True: 需要继续使用工具 False: 可以生成最终回答 """ _ = messages # 如果工具执行失败,继续尝试 if not tool_result.success: return True # 使用 LLM 评估 evaluation_prompt = get_prompt( "agent", "task_evaluation", user_query=user_query, tool_result=tool_result.content[:500], # 限制长度 ) if not evaluation_prompt: evaluation_prompt = self._get_default_evaluation_prompt() try: eval_messages = [ {"role": "system", "content": evaluation_prompt}, { "role": "user", "content": (f"用户查询: {user_query}\n\n工具结果: {tool_result.content[:500]}"), }, ] client = self.llm_client._get_client() response = client.chat.completions.create( model=self.llm_client.model, messages=cast("list[ChatCompletionMessageParam]", eval_messages), temperature=0.1, max_tokens=100, ) eval_text = (response.choices[0].message.content or "").strip().lower() # 简单判断:如果包含"完成"、"足够"等关键词,认为可以生成回答 completion_keywords = ["完成", "足够", "可以", "complete", "sufficient"] return not any(keyword in eval_text for keyword in completion_keywords) except Exception as e: logger.error(f"[Agent] 任务评估失败: {e}") # 默认继续 return True def _generate_final_response( self, user_query: str, messages: list[dict], accumulated_context: list[str], ) -> Generator[str]: """生成最终回答""" # 构建包含所有工具结果的最终消息 final_messages = messages.copy() # 检查是否使用了 web_search 工具(通过检查消息历史) used_web_search = any( msg.get("content", "").startswith("[工具调用: web_search]") for msg in messages ) if accumulated_context: # 如果有工具结果,构建强调工具结果的用户消息 context_text = "\n\n".join(accumulated_context) logger.info( f"[Agent] 生成最终回答,工具结果长度: {len(context_text)} 字符", ) # 构建用户消息 base_instruction = ( f"用户问题:{user_query}\n\n" f"工具执行结果:\n{context_text}\n\n" "请严格基于上述工具执行结果回答用户的问题。" "如果工具结果中包含相关信息,必须使用这些信息。" "不要使用过时的知识或猜测,只基于工具提供的搜索结果。" "当工具结果与你的训练数据冲突时,以工具结果为准(工具结果代表最新的实时信息)。" ) # 如果使用了 web_search,添加 Sources 格式要求 if used_web_search: base_instruction += ( "\n\n**重要格式要求(必须严格遵守):**" "\n1. 在回答中引用信息时,必须使用引用标记格式:[[1]]、[[2]] 等,数字对应搜索结果编号" '\n2. 在回答的末尾,必须添加一个 "Sources:" 段落,列出所有引用的来源' "\n3. Sources 段落的格式必须严格按照以下格式(与工具执行结果中的格式一致):" "\n Sources:" "\n 1. 标题 (URL)" "\n 2. 标题 (URL)" "\n ..." "\n4. 工具执行结果中已经包含了 Sources 列表,请直接使用这些来源信息,不要修改格式" '\n5. 确保 Sources 段落与回答正文之间有两个空行(即 "\\n\\nSources:")' ) final_messages.append( { "role": "user", "content": base_instruction, } ) else: # 没有工具结果,直接基于原始查询回答 # 重要:明确告诉 LLM 不要假装使用工具 final_messages.append( { "role": "user", "content": ( f"{user_query}\n\n" "**重要提示:**\n" "本次回答没有使用任何工具。请直接基于你的知识回答," "不要提及'正在搜索'、'使用工具'、'工具执行'、'web_search'等词汇," "不要生成工具调用的描述,不要假装使用了工具。" "如果问题需要最新信息但你无法提供,请诚实说明。" ), } ) # 流式生成回答 try: yield from self.llm_client.stream_chat( messages=final_messages, temperature=0.7, ) except Exception as e: logger.error(f"[Agent] 生成最终回答失败: {e}") yield f"生成回答时出现错误: {e!s}" def _get_default_system_prompt(self) -> str: """默认系统提示词""" return """你是一个智能助手,可以使用工具来帮助用户完成任务。 你可以使用以下工具: - web_search: 联网搜索最新信息 当用户需要实时信息、最新资讯时,你应该使用 web_search 工具。 使用工具后,基于工具返回的结果生成准确、有用的回答。""" def _get_default_tool_selection_prompt( self, tools_schema: list[dict], ) -> str: """默认工具选择提示词""" tools_desc = "\n".join( [f"- {tool['name']}: {tool['description']}" for tool in tools_schema] ) return f"""分析用户查询,判断是否需要使用工具。 可用工具: {tools_desc} 请以 JSON 格式返回: {{ "use_tool": true/false, "tool_name": "工具名称" 或 null, "tool_params": {{"参数名": "参数值"}} 或 {{}} }} 只返回 JSON,不要返回其他信息。""" def _get_default_evaluation_prompt(self) -> str: """默认任务评估提示词""" return """评估工具执行结果是否足够回答用户的问题。 如果工具结果已经包含足够信息来回答用户问题,返回"完成"。 如果需要更多信息,返回"继续"。 只返回"完成"或"继续"。""" ================================================ FILE: lifetrace/llm/agno_agent.py ================================================ """Agno Agent 服务,基于 Agno 框架的通用 Agent 实现 支持 FreeTodoToolkit 工具集和国际化消息。 支持工具调用事件流,可在前端实时展示 Agent 执行步骤。 支持 Phoenix + OpenInference 观测(通过配置启用)。 支持 session_id 传递,实现按会话聚合 trace 文件。 支持外部工具(如 DuckDuckGo 搜索)。 """ from __future__ import annotations import importlib import inspect import json from contextvars import ContextVar from pathlib import Path from typing import TYPE_CHECKING from agno.agent import Agent, Message, RunEvent from agno.models.openai.like import OpenAILike from lifetrace.llm.agno_tools import FreeTodoToolkit from lifetrace.llm.agno_tools.base import get_message from lifetrace.observability import setup_observability from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings if TYPE_CHECKING: from collections.abc import Generator from agno.tools import Toolkit # 全局 ContextVar 用于跨 span 传递 session_id # file_exporter 可以读取这个值来按 session 聚合文件 current_session_id: ContextVar[str | None] = ContextVar("current_session_id", default=None) logger = get_logger() # 初始化观测系统(在模块加载时执行一次) # 如果配置中 observability.enabled = false,则不会有任何影响 setup_observability() # Default language, can be overridden from settings DEFAULT_LANG = "en" # 工具调用事件标记(用于流式输出中区分内容和工具调用事件) TOOL_EVENT_PREFIX = "\n[TOOL_EVENT:" TOOL_EVENT_SUFFIX = "]\n" # 工具结果预览最大长度 RESULT_PREVIEW_MAX_LENGTH = 500 # 可用的外部工具映射 EXTERNAL_TOOLS_REGISTRY: dict[str, type[Toolkit]] = {} def _try_register_tool(name: str, module_path: str, class_name: str, warning: str = ""): """尝试注册单个工具""" try: module = importlib.import_module(module_path) tool_class = getattr(module, class_name) EXTERNAL_TOOLS_REGISTRY[name] = tool_class logger.debug(f"已注册外部工具: {name}") except ImportError: logger.warning(warning or f"无法导入 {class_name}") def _ensure_tool_dependency(tool_name: str, package_name: str) -> bool: """检查外部工具依赖是否可用""" try: importlib.import_module(package_name) except ImportError: logger.warning(f"{tool_name} 工具依赖 {package_name} 包,未安装,跳过注册") return False return True def _register_external_tools(): """注册可用的外部工具(延迟导入以避免启动时的依赖问题)""" if EXTERNAL_TOOLS_REGISTRY: return # 工具注册配置: (名称, 模块路径, 类名, 警告信息, 依赖包) tools_config = [ # 搜索类工具 ("websearch", "agno.tools.websearch", "WebSearchTools", "请确保已安装 ddgs 包", "ddgs"), ("hackernews", "agno.tools.hackernews", "HackerNewsTools", "", None), # 本地工具 ("file", "agno.tools.file", "FileTools", "", None), ("local_fs", "agno.tools.local_file_system", "LocalFileSystemTools", "", None), ("shell", "agno.tools.shell", "ShellTools", "", None), ("sleep", "agno.tools.sleep", "SleepTools", "", None), ] for name, module_path, class_name, warning, dependency in tools_config: if dependency and not _ensure_tool_dependency(name, dependency): continue _try_register_tool(name, module_path, class_name, warning) def get_available_external_tools() -> list[str]: """获取可用的外部工具列表""" _register_external_tools() return list(EXTERNAL_TOOLS_REGISTRY.keys()) def _create_file_tool(tool_class, **kwargs) -> Toolkit | None: """创建 FileTools 实例""" base_dir = kwargs.get("base_dir") if not base_dir: logger.warning("FileTools 需要 base_dir 参数,跳过创建") return None # FileTools 需要 Path 对象,而不是字符串 base_dir_path = Path(base_dir) if isinstance(base_dir, str) else base_dir return tool_class( base_dir=base_dir_path, enable_save_file=True, enable_read_file=True, enable_read_file_chunk=True, enable_replace_file_chunk=True, enable_list_files=True, enable_search_files=True, enable_delete_file=kwargs.get("enable_delete", False), ) def _safe_tool_init(tool_class, **kwargs) -> Toolkit: """安全初始化工具,兼容不同版本的构造参数""" try: return tool_class(**kwargs) except TypeError as exc: if "unexpected keyword argument" not in str(exc): raise try: sig = inspect.signature(tool_class.__init__) except (TypeError, ValueError): return tool_class() allowed_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters} if not allowed_kwargs: return tool_class() return tool_class(**allowed_kwargs) def create_external_tool(tool_name: str, **kwargs) -> Toolkit | None: # noqa: PLR0911 """创建外部工具实例 可用工具: 搜索类: websearch, hackernews 本地类: file(需要base_dir), local_fs, shell, sleep """ _register_external_tools() tool_class = EXTERNAL_TOOLS_REGISTRY.get(tool_name) if not tool_class: return None base_dir = kwargs.get("base_dir") # 搜索类工具 if tool_name == "websearch": return _safe_tool_init(tool_class, backend="auto", search=True, news=True) if tool_name in ("hackernews", "sleep"): return _safe_tool_init(tool_class) # 本地工具 if tool_name == "file": return _create_file_tool(tool_class, **kwargs) if tool_name == "local_fs": # 确保使用 Path 对象 base_dir_path = Path(base_dir) if isinstance(base_dir, str) else base_dir return ( _safe_tool_init(tool_class, target_directory=base_dir_path) if base_dir else _safe_tool_init(tool_class) ) if tool_name == "shell": # 确保使用 Path 对象 base_dir_path = Path(base_dir) if isinstance(base_dir, str) else base_dir return ( _safe_tool_init(tool_class, base_dir=base_dir_path) if base_dir else _safe_tool_init(tool_class) ) return _safe_tool_init(tool_class) def _build_instructions( lang: str, has_tools: bool, use_all_freetodo_tools: bool, has_external_tools: bool, ) -> list[str] | None: """构建 Agent 的 instructions Args: lang: 语言代码 has_tools: 是否有任何工具启用 use_all_freetodo_tools: 是否使用全部 FreeTodo 工具 has_external_tools: 是否有外部工具 Returns: instructions 列表或 None """ if use_all_freetodo_tools and not has_external_tools: # Load full instructions from agno_tools/{lang}/instructions.yaml instructions = get_message(lang, "instructions") return [instructions] if instructions and instructions != "[instructions]" else None # 简化的 instructions if lang == "zh": if has_tools: return [ "你是 FreeTodo 智能助手,可以帮助用户管理待办事项和执行各种任务。" "请根据用户的问题选择合适的工具来完成任务。" ] return ["你是 FreeTodo 智能助手。当前没有启用任何工具,请直接回答用户的问题。"] # English if has_tools: return [ "You are the FreeTodo assistant that helps users manage their todos " "and perform various tasks. Use the appropriate tools to complete tasks." ] return [ "You are the FreeTodo assistant. No tools are currently enabled. " "Please answer the user's questions directly." ] class AgnoAgentService: """Agno Agent 服务,提供基于 Agno 框架的智能对话能力 Supports: - FreeTodoToolkit for todo management - External tools (DuckDuckGo search, etc.) - Internationalization (i18n) through lang parameter - Streaming responses """ def __init__( self, lang: str | None = None, selected_tools: list[str] | None = None, external_tools: list[str] | None = None, external_tools_config: dict[str, dict] | None = None, ): """初始化 Agno Agent 服务 Args: lang: Language code for messages ('zh' or 'en'). If None, uses DEFAULT_LANG or settings default. selected_tools: List of FreeTodo tool names to enable. If None or empty, no FreeTodo tools are enabled. external_tools: List of external tool names to enable (e.g., ['duckduckgo', 'file']). If None or empty, no external tools are enabled. external_tools_config: Configuration dict for external tools. Example: {"file": {"base_dir": "/path/to/workspace", "enable_delete": False}} """ try: self.lang = lang or DEFAULT_LANG tools_to_use = self._initialize_tools( selected_tools, external_tools, external_tools_config ) # 判断工具配置 total_freetodo_tools_count = 14 use_all_freetodo_tools = bool( selected_tools and len(selected_tools) == total_freetodo_tools_count ) has_external_tools = bool(external_tools and len(external_tools) > 0) instructions_list = _build_instructions( self.lang, bool(tools_to_use), use_all_freetodo_tools, has_external_tools ) self.agent = Agent( model=OpenAILike( id=settings.llm.model, api_key=settings.llm.api_key, base_url=settings.llm.base_url, ), tools=tools_to_use if tools_to_use else None, instructions=instructions_list, markdown=True, ) logger.info( f"Agno Agent 初始化成功,模型: {settings.llm.model}, " f"Base URL: {settings.llm.base_url}, lang: {self.lang}, " f"工具数量: {len(tools_to_use)}", ) except Exception as e: logger.error(f"Agno Agent 初始化失败: {e}") raise def _initialize_tools( self, selected_tools: list[str] | None, external_tools: list[str] | None, external_tools_config: dict[str, dict] | None = None, ) -> list[Toolkit]: """初始化工具列表 Args: selected_tools: FreeTodo 工具名称列表 external_tools: 外部工具名称列表 external_tools_config: 外部工具配置字典,如 {"file": {"base_dir": "/path"}} """ tools_to_use: list[Toolkit] = [] external_tools_config = external_tools_config or {} # Initialize FreeTodoToolkit if any tools are selected if selected_tools and len(selected_tools) > 0: toolkit = FreeTodoToolkit(lang=self.lang, selected_tools=selected_tools) tools_to_use.append(toolkit) logger.info(f"已启用 FreeTodo 工具: {selected_tools}") # Initialize external tools with config if external_tools and len(external_tools) > 0: for tool_name in external_tools: # 获取该工具的配置 config = external_tools_config.get(tool_name, {}) external_tool = create_external_tool(tool_name, **config) if external_tool: tools_to_use.append(external_tool) logger.info(f"已启用外部工具: {tool_name}, 配置: {config}") else: logger.warning(f"未找到或无法创建外部工具: {tool_name}") return tools_to_use def _build_input_data( self, message: str, conversation_history: list[dict[str, str]] | None, ): """构建 Agent 输入数据""" if not conversation_history: return message messages = [] for msg in conversation_history: role = msg.get("role", "user") content = msg.get("content", "") if role in ("user", "assistant"): messages.append(Message(role=role, content=content)) messages.append(Message(role="user", content=message)) return messages def _format_tool_event(self, event_data: dict) -> str: """格式化工具事件为输出字符串""" return f"{TOOL_EVENT_PREFIX}{json.dumps(event_data, ensure_ascii=False)}{TOOL_EVENT_SUFFIX}" def _handle_tool_call_started(self, chunk) -> str | None: """处理工具调用开始事件""" tool_info = getattr(chunk, "tool", None) if not tool_info: return None event_data = { "type": "tool_call_start", "tool_name": getattr(tool_info, "tool_name", "unknown"), "tool_args": getattr(tool_info, "tool_args", {}), } logger.debug(f"工具调用开始: {event_data['tool_name']}, 参数: {event_data['tool_args']}") return self._format_tool_event(event_data) def _handle_tool_call_completed(self, chunk) -> str | None: """处理工具调用完成事件""" tool_info = getattr(chunk, "tool", None) if not tool_info: return None result = getattr(tool_info, "result", "") result_str = str(result) result_preview = ( result_str[:RESULT_PREVIEW_MAX_LENGTH] + "..." if len(result_str) > RESULT_PREVIEW_MAX_LENGTH else result_str ) event_data = { "type": "tool_call_end", "tool_name": getattr(tool_info, "tool_name", "unknown"), "result_preview": result_preview, } logger.debug( f"工具调用完成: {event_data['tool_name']}, 结果预览: {result_preview[:100]}..." ) return self._format_tool_event(event_data) def _handle_tool_call_error(self, chunk) -> str | None: """处理工具调用错误事件""" tool_info = getattr(chunk, "tool", None) if not tool_info: return None error = getattr(tool_info, "error", None) or getattr(chunk, "error", None) error_str = str(error) if error else "Unknown error" error_preview = ( error_str[:RESULT_PREVIEW_MAX_LENGTH] + "..." if len(error_str) > RESULT_PREVIEW_MAX_LENGTH else error_str ) event_data = { "type": "tool_call_end", "tool_name": getattr(tool_info, "tool_name", "unknown"), "result_preview": f"[Error] {error_preview}", "error": True, } logger.warning(f"工具调用错误: {event_data['tool_name']}, 错误: {error_preview[:100]}...") return self._format_tool_event(event_data) def _process_stream_chunk(self, chunk, include_tool_events: bool) -> str | None: """处理单个流式输出块,返回需要 yield 的内容""" result = None if chunk.event == RunEvent.run_content: result = chunk.content if chunk.content else None elif include_tool_events: if chunk.event == RunEvent.tool_call_started: result = self._handle_tool_call_started(chunk) elif chunk.event == RunEvent.tool_call_completed: result = self._handle_tool_call_completed(chunk) elif chunk.event == RunEvent.tool_call_error: # 处理工具调用错误事件,发送 tool_call_end 以便前端更新状态 result = self._handle_tool_call_error(chunk) elif chunk.event == RunEvent.run_started: logger.debug("Agent 运行开始") result = self._format_tool_event({"type": "run_started"}) elif chunk.event == RunEvent.run_completed: logger.debug("Agent 运行完成") result = self._format_tool_event({"type": "run_completed"}) return result def stream_response( self, message: str, conversation_history: list[dict[str, str]] | None = None, include_tool_events: bool = True, session_id: str | None = None, ) -> Generator[str]: """ 流式生成 Agent 回复 Args: message: 用户消息 conversation_history: 对话历史,格式为 [{"role": "user|assistant", "content": "..."}] include_tool_events: 是否包含工具调用事件(默认 True) session_id: 会话 ID,用于 trace 文件按会话聚合和 Phoenix session 追踪 Yields: 回复内容片段(字符串),如果 include_tool_events=True, 工具调用事件会以特殊格式输出:[TOOL_EVENT:{"type":"...","data":{...}}] """ # 设置本地 ContextVar(用于 file_exporter 按会话聚合) current_session_id.set(session_id) try: input_data = self._build_input_data(message, conversation_history) # 直接将 session_id 传递给 agent.run() # Agno Instrumentor 会从参数中读取 session_id 并设置为 span 属性 stream = self.agent.run( input_data, stream=True, stream_events=include_tool_events, session_id=session_id, # 传递给 Agno,用于 Phoenix session 追踪 ) for chunk in stream: output = self._process_stream_chunk(chunk, include_tool_events) if output: yield output except Exception as e: logger.error(f"Agno Agent 流式生成失败: {e}") yield f"Agno Agent 处理失败: {e!s}" finally: # 清理 ContextVar current_session_id.set(None) def is_available(self) -> bool: """检查 Agno Agent 是否可用""" return hasattr(self, "agent") and self.agent is not None ================================================ FILE: lifetrace/llm/agno_tools/__init__.py ================================================ """Agno Tools - FreeTodo Toolkit for Agno Agent This module provides tools for managing todos through the Agno Agent framework. Structure: - toolkit.py: Main FreeTodoToolkit class - base.py: Message loader and utilities - tools/: Individual tool implementations - todo_tools.py: CRUD operations - breakdown_tools.py: Task breakdown - time_tools.py: Time parsing - conflict_tools.py: Schedule conflict detection - stats_tools.py: Statistics and analysis - tag_tools.py: Tag management """ from lifetrace.llm.agno_tools.toolkit import FreeTodoToolkit __all__ = ["FreeTodoToolkit"] ================================================ FILE: lifetrace/llm/agno_tools/base.py ================================================ """Base module for Agno Tools Provides message loader and base utilities for all tools. """ from __future__ import annotations from pathlib import Path from typing import Any, ClassVar import yaml from lifetrace.util.base_paths import get_config_dir from lifetrace.util.logging_config import get_logger logger = get_logger() class AgnoToolsMessageLoader: """Message loader for Agno Tools Loads localized messages from YAML files based on language. Supports caching for performance. """ _instances: ClassVar[dict[str, AgnoToolsMessageLoader]] = {} _messages: ClassVar[dict[str, dict[str, Any]]] = {} def __new__(cls, lang: str = "en"): """Singleton per language""" if lang not in cls._instances: instance = super().__new__(cls) cls._instances[lang] = instance return cls._instances[lang] def __init__(self, lang: str = "en"): """Initialize message loader Args: lang: Language code ('zh' or 'en') """ self.lang = lang if lang not in self._messages: self._load_messages() def _get_prompts_dir(self) -> Path: """Get the prompts directory path""" try: return get_config_dir() / "prompts" / "agno_tools" / self.lang except ImportError: # Fallback for testing return ( Path(__file__).parent.parent.parent / "config" / "prompts" / "agno_tools" / self.lang ) def _load_messages(self): """Load all YAML files from the language directory""" prompts_dir = self._get_prompts_dir() self._messages[self.lang] = {} if not prompts_dir.exists(): logger.warning(f"Prompts directory not found: {prompts_dir}") return yaml_files = list(prompts_dir.glob("*.yaml")) for yaml_file in yaml_files: try: with open(yaml_file, encoding="utf-8") as f: data = yaml.safe_load(f) or {} self._messages[self.lang].update(data) except Exception as e: logger.error(f"Failed to load {yaml_file.name}: {e}") logger.info( f"AgnoTools messages loaded for '{self.lang}': " f"{len(yaml_files)} files, {len(self._messages[self.lang])} keys" ) def get(self, key: str, **kwargs) -> str: """Get a localized message by key Args: key: Message key **kwargs: Format arguments Returns: Formatted message string """ messages = self._messages.get(self.lang, {}) template = messages.get(key, "") if not template: # Fallback to English if self.lang != "en": en_messages = self._messages.get("en", {}) template = en_messages.get(key, "") if not template: logger.warning(f"Message not found: {key}") return f"[{key}]" try: if kwargs: return template.format(**kwargs) return template except KeyError as e: logger.error(f"Missing format key in message '{key}': {e}") return template def reload(self): """Reload messages from disk""" if self.lang in self._messages: del self._messages[self.lang] self._load_messages() def get_message(lang: str, key: str, **kwargs) -> str: """Convenience function to get a localized message Args: lang: Language code key: Message key **kwargs: Format arguments Returns: Formatted message string """ loader = AgnoToolsMessageLoader(lang) return loader.get(key, **kwargs) ================================================ FILE: lifetrace/llm/agno_tools/toolkit.py ================================================ """FreeTodo Toolkit for Agno Agent Main toolkit class that combines all tool mixins. """ from __future__ import annotations import importlib from agno.tools import Toolkit from lifetrace.llm.agno_tools.base import AgnoToolsMessageLoader from lifetrace.llm.agno_tools.tools import ( BreakdownTools, ConflictTools, StatsTools, TagTools, TimeTools, TodoTools, ) from lifetrace.util.logging_config import get_logger logger = get_logger() class FreeTodoToolkit( TodoTools, BreakdownTools, TimeTools, ConflictTools, StatsTools, TagTools, Toolkit, ): """FreeTodo Toolkit - Todo management tools for Agno Agent Combines all tool mixins into a single Toolkit. Supports internationalization through lang parameter. Tools included: - Todo CRUD: create_todo, complete_todo, update_todo, list_todos, search_todos, delete_todo - Task breakdown: breakdown_task - Time parsing: parse_time - Conflict detection: check_schedule_conflict - Statistics: get_todo_stats, get_overdue_todos - Tag management: list_tags, get_todos_by_tag, suggest_tags """ def __init__(self, lang: str = "en", selected_tools: list[str] | None = None, **kwargs): """Initialize FreeTodoToolkit Args: lang: Language code for messages ('zh' or 'en'), defaults to 'en' selected_tools: List of tool names to enable. If None or empty, no tools are enabled. **kwargs: Additional arguments passed to Toolkit base class """ self.lang = lang # Initialize message loader (preload messages) AgnoToolsMessageLoader(lang) # Lazy import to avoid circular dependencies repo_module = importlib.import_module("lifetrace.repositories.sql_todo_repository") db_module = importlib.import_module("lifetrace.storage.database") sql_todo_repository_class = repo_module.SqlTodoRepository db_base = db_module.db_base self.db_base = db_base self.todo_repo = sql_todo_repository_class(db_base) # All available tools all_tools = { # Todo management (from TodoTools) "create_todo": self.create_todo, "complete_todo": self.complete_todo, "update_todo": self.update_todo, "list_todos": self.list_todos, "search_todos": self.search_todos, "delete_todo": self.delete_todo, # Task breakdown (from BreakdownTools) "breakdown_task": self.breakdown_task, # Time parsing (from TimeTools) "parse_time": self.parse_time, # Conflict detection (from ConflictTools) "check_schedule_conflict": self.check_schedule_conflict, # Statistics (from StatsTools) "get_todo_stats": self.get_todo_stats, "get_overdue_todos": self.get_overdue_todos, # Tag management (from TagTools) "list_tags": self.list_tags, "get_todos_by_tag": self.get_todos_by_tag, "suggest_tags": self.suggest_tags, } # Filter tools based on selected_tools # Default: no tools enabled (user must explicitly select tools) if selected_tools and len(selected_tools) > 0: tools = [all_tools[tool_name] for tool_name in selected_tools if tool_name in all_tools] logger.info( f"FreeTodoToolkit initialized with lang={lang}, " f"selected {len(tools)} tools: {selected_tools}" ) else: tools = [] logger.info(f"FreeTodoToolkit initialized with lang={lang}, no tools enabled (default)") super().__init__(name="freetodo_toolkit", tools=tools, **kwargs) ================================================ FILE: lifetrace/llm/agno_tools/tools/__init__.py ================================================ """Tools subpackage for Agno Tools Contains individual tool implementations organized by functionality. """ from lifetrace.llm.agno_tools.tools.breakdown_tools import BreakdownTools from lifetrace.llm.agno_tools.tools.conflict_tools import ConflictTools from lifetrace.llm.agno_tools.tools.stats_tools import StatsTools from lifetrace.llm.agno_tools.tools.tag_tools import TagTools from lifetrace.llm.agno_tools.tools.time_tools import TimeTools from lifetrace.llm.agno_tools.tools.todo_tools import TodoTools __all__ = [ "BreakdownTools", "ConflictTools", "StatsTools", "TagTools", "TimeTools", "TodoTools", ] ================================================ FILE: lifetrace/llm/agno_tools/tools/breakdown_tools.py ================================================ """Task Breakdown Tools Tools for breaking down complex tasks into subtasks. The Agent directly breaks down tasks without nested LLM calls for better performance. """ from __future__ import annotations from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger logger = get_logger() class BreakdownTools: """Task breakdown tools mixin""" lang: str def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def breakdown_task(self, task_description: str) -> str: """Break down a complex task into subtasks This tool provides context for task breakdown. The Agent should directly break down the task into subtasks without calling LLM again. Args: task_description: Description of the task to break down Returns: Instructions for the Agent to break down the task directly """ # 返回拆解指导信息,让 Agent 自己完成拆解 # 这样可以避免嵌套 LLM 调用,提升性能 breakdown_guide = self._msg("breakdown_guide", task_description=task_description) return breakdown_guide ================================================ FILE: lifetrace/llm/agno_tools/tools/conflict_tools.py ================================================ """Conflict Detection Tools Schedule conflict detection for todos. """ from __future__ import annotations import contextlib from datetime import datetime, timedelta from typing import TYPE_CHECKING from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger if TYPE_CHECKING: from lifetrace.repositories.sql_todo_repository import SqlTodoRepository logger = get_logger() # Default duration for todos without explicit end time DEFAULT_TODO_DURATION_HOURS = 1 def _parse_datetime(value: str | datetime) -> datetime: """Parse datetime from string or return as-is if already datetime.""" if isinstance(value, str): return datetime.fromisoformat(value.replace("Z", "+00:00")) return value def _parse_duration_value(value: str | None) -> timedelta | None: # noqa: C901, PLR0912 if not value: return None match = value.strip().upper().removeprefix("P") if not match: return None # Basic ISO 8601 duration parsing: PnW, PnD, PTnHnMnS. weeks = days = hours = minutes = seconds = 0 if "T" in match: date_part, time_part = match.split("T", 1) else: date_part, time_part = match, "" if date_part.endswith("W"): with contextlib.suppress(ValueError): weeks = int(date_part[:-1] or 0) date_part = "" if date_part.endswith("D"): with contextlib.suppress(ValueError): days = int(date_part[:-1] or 0) if time_part: number = "" value_int = 0 for ch in time_part: if ch.isdigit(): number += ch continue with contextlib.suppress(ValueError): value_int = int(number or 0) if ch == "H": hours = value_int elif ch == "M": minutes = value_int elif ch == "S": seconds = value_int number = "" total_days = days + weeks * 7 if total_days == hours == minutes == seconds == 0: return None return timedelta(days=total_days, hours=hours, minutes=minutes, seconds=seconds) def _get_todo_range(todo: dict) -> tuple[datetime, datetime] | None: start_raw = todo.get("dtstart") or todo.get("start_time") end_raw = todo.get("dtend") or todo.get("end_time") due_raw = todo.get("due") or todo.get("deadline") if not start_raw: start_raw = due_raw if not start_raw: return None todo_start = _parse_datetime(start_raw) if not end_raw and due_raw and start_raw is not due_raw: end_raw = due_raw if end_raw: todo_end = _parse_datetime(end_raw) else: duration_raw = todo.get("duration") duration = _parse_duration_value(duration_raw) if duration is not None: try: todo_end = todo_start + duration except Exception: todo_end = todo_start + timedelta(hours=DEFAULT_TODO_DURATION_HOURS) else: todo_end = todo_start + timedelta(hours=DEFAULT_TODO_DURATION_HOURS) return todo_start, todo_end def _check_schedule_conflict(todo: dict, start: datetime, end: datetime, conflicts: list) -> None: """Check if todo schedule overlaps with the specified range.""" todo_range = _get_todo_range(todo) if not todo_range: return todo_start, todo_end = todo_range if start < todo_end and end > todo_start: existing_ids = [c["id"] for c in conflicts] if todo["id"] not in existing_ids: conflicts.append( {"id": todo["id"], "name": todo["name"], "start": todo_start, "end": todo_end} ) def _find_conflicts(todos: list, start: datetime, end: datetime) -> list: """Find all conflicting todos within the time range.""" conflicts = [] for todo in todos: _check_schedule_conflict(todo, start, end, conflicts) return conflicts class ConflictTools: """Conflict detection tools mixin""" lang: str todo_repo: SqlTodoRepository def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def _format_conflict_result(self, conflicts: list, time_range: str) -> str: """Format conflict check result as message.""" if not conflicts: return self._msg("no_conflict", time_range=time_range) conflict_lines = [ self._msg( "conflict_item", id=c["id"], name=c["name"], start=c["start"].strftime("%H:%M") if c.get("start") else "N/A", end=c["end"].strftime("%H:%M") if c.get("end") else "", ) for c in conflicts ] return self._msg( "conflict_found", time_range=time_range, count=len(conflicts), conflicts="\n".join(conflict_lines), ) def check_schedule_conflict(self, start_time: str, end_time: str | None = None) -> str: """Check if the specified time conflicts with existing todos Args: start_time: Start time in ISO format end_time: End time in ISO format (optional, defaults to start_time + 1 hour) Returns: Conflict information or availability message """ try: start = datetime.fromisoformat(start_time.replace("Z", "+00:00")) end = ( datetime.fromisoformat(end_time.replace("Z", "+00:00")) if end_time else start + timedelta(hours=DEFAULT_TODO_DURATION_HOURS) ) time_range = f"{start.strftime('%Y-%m-%d %H:%M')} - {end.strftime('%H:%M')}" todos = self.todo_repo.list_todos(limit=200, offset=0, status="active") conflicts = _find_conflicts(todos, start, end) return self._format_conflict_result(conflicts, time_range) except Exception as e: logger.error(f"Failed to check schedule conflict: {e}") return self._msg("conflict_failed", error=str(e)) ================================================ FILE: lifetrace/llm/agno_tools/tools/stats_tools.py ================================================ """Statistics Tools Todo statistics and analysis. """ from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now if TYPE_CHECKING: from lifetrace.repositories.sql_todo_repository import SqlTodoRepository logger = get_logger() def _parse_datetime(value: str | datetime) -> datetime: """Parse datetime from string or return as-is if already datetime.""" if isinstance(value, str): return datetime.fromisoformat(value.replace("Z", "+00:00")) return value def _get_schedule_time(todo: dict) -> datetime | None: """Return schedule time from todo with legacy fallback.""" schedule = ( todo.get("due") or todo.get("dtstart") or todo.get("deadline") or todo.get("start_time") ) if not schedule: return None return _parse_datetime(schedule) def _get_start_date(date_range: str, now: datetime) -> datetime | None: """Get start date based on date range string.""" if date_range == "today": return now.replace(hour=0, minute=0, second=0, microsecond=0) if date_range == "week": return now - timedelta(days=7) if date_range == "month": return now - timedelta(days=30) return None def _filter_by_date(todos: list, start_date: datetime | None) -> list: """Filter todos by start date.""" if not start_date: return todos return [ t for t in todos if t.get("created_at") and _parse_datetime(t["created_at"]) >= start_date ] def _count_overdue(todos: list, now: datetime) -> int: """Count overdue active todos.""" count = 0 for t in todos: if t.get("status") != "active": continue schedule = _get_schedule_time(t) if schedule and schedule < now: count += 1 return count def _count_by_priority(todos: list) -> dict: """Count todos by priority level.""" counts = {"high": 0, "medium": 0, "low": 0, "none": 0} for t in todos: priority = t.get("priority", "none") if priority in counts: counts[priority] += 1 return counts class StatsTools: """Statistics tools mixin""" lang: str todo_repo: SqlTodoRepository def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def get_todo_stats(self, date_range: str = "today") -> str: """Get todo statistics Args: date_range: Time range - 'today', 'week', 'month', 'all' (default: 'today') Returns: Formatted statistics """ try: all_todos = self.todo_repo.list_todos(limit=1000, offset=0, status=None) now = get_utc_now() start_date = _get_start_date(date_range, now) filtered_todos = _filter_by_date(all_todos, start_date) total = len(filtered_todos) completed = sum(1 for t in filtered_todos if t.get("status") == "completed") active = sum(1 for t in filtered_todos if t.get("status") == "active") overdue = _count_overdue(filtered_todos, now) priority_counts = _count_by_priority(filtered_todos) result = self._msg("stats_header", date_range=date_range) result += self._msg("stats_total", total=total) + "\n" result += self._msg("stats_completed", completed=completed) + "\n" result += self._msg("stats_active", active=active) + "\n" result += self._msg("stats_overdue", overdue=overdue) + "\n" result += self._msg( "stats_by_priority", high=priority_counts["high"], medium=priority_counts["medium"], low=priority_counts["low"], none=priority_counts["none"], ) return result except Exception as e: logger.error(f"Failed to get todo stats: {e}") return self._msg("stats_failed", error=str(e)) def get_overdue_todos(self) -> str: """Get all overdue todos Returns: Formatted list of overdue todos """ try: now = get_utc_now() todos = self.todo_repo.list_todos(limit=200, offset=0, status="active") overdue = [] for todo in todos: schedule = _get_schedule_time(todo) if not schedule: continue if schedule < now: days_overdue = (now - schedule).days overdue.append({"id": todo["id"], "name": todo["name"], "days": days_overdue}) if not overdue: return self._msg("no_overdue") overdue.sort(key=lambda x: x["days"], reverse=True) result = self._msg("overdue_header", count=len(overdue)) for item in overdue: result += ( self._msg("overdue_item", id=item["id"], name=item["name"], days=item["days"]) + "\n" ) return result.strip() except Exception as e: logger.error(f"Failed to get overdue todos: {e}") return self._msg("no_overdue") ================================================ FILE: lifetrace/llm/agno_tools/tools/tag_tools.py ================================================ """Tag Management Tools Tag listing, filtering, and suggestion. The Agent directly suggests tags without nested LLM calls for better performance. """ from __future__ import annotations from typing import TYPE_CHECKING from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger if TYPE_CHECKING: from lifetrace.repositories.sql_todo_repository import SqlTodoRepository logger = get_logger() class TagTools: """Tag management tools mixin""" lang: str todo_repo: SqlTodoRepository def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def list_tags(self) -> str: """List all used tags with todo counts Returns: Formatted list of tags """ try: todos = self.todo_repo.list_todos(limit=1000, offset=0, status=None) tag_counts: dict[str, int] = {} for todo in todos: tags = todo.get("tags", []) if tags: for tag in tags: tag_counts[tag] = tag_counts.get(tag, 0) + 1 if not tag_counts: return self._msg("tags_empty") sorted_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True) result = self._msg("tags_header", count=len(sorted_tags)) for tag, count in sorted_tags: result += self._msg("tags_item", tag=tag, count=count) + "\n" return result.strip() except Exception as e: logger.error(f"Failed to list tags: {e}") return self._msg("tags_empty") def get_todos_by_tag(self, tag: str) -> str: """Get all todos with a specific tag Args: tag: Tag name to filter by Returns: Formatted list of todos with the tag """ try: todos = self.todo_repo.list_todos(limit=200, offset=0, status=None) matches = [todo for todo in todos if tag in (todo.get("tags") or [])] if not matches: return self._msg("todos_by_tag_empty", tag=tag) result = self._msg("todos_by_tag_header", tag=tag, count=len(matches)) for todo in matches: result += ( self._msg( "todos_by_tag_item", id=todo["id"], status=todo.get("status", "active"), name=todo["name"], ) + "\n" ) return result.strip() except Exception as e: logger.error(f"Failed to get todos by tag: {e}") return self._msg("todos_by_tag_empty", tag=tag) def suggest_tags(self, todo_name: str) -> str: """Suggest tags based on todo name This tool provides context for tag suggestion. The Agent should directly suggest tags without calling LLM again. Args: todo_name: Name of the todo to suggest tags for Returns: Instructions for the Agent to suggest tags directly """ try: # 获取现有标签作为参考 todos = self.todo_repo.list_todos(limit=500, offset=0, status=None) existing_tags = set() for todo in todos: for tag in todo.get("tags") or []: existing_tags.add(tag) existing_tags_str = ", ".join(sorted(existing_tags)) if existing_tags else "None" # 返回推荐指导信息,让 Agent 自己完成推荐 # 这样可以避免嵌套 LLM 调用,提升性能 suggestion_guide = self._msg( "suggest_tags_guide", todo_name=todo_name, existing_tags=existing_tags_str, ) return suggestion_guide except Exception as e: logger.error(f"Failed to get tag suggestion context: {e}") return self._msg("suggest_tags_failed", error=str(e)) ================================================ FILE: lifetrace/llm/agno_tools/tools/time_tools.py ================================================ """Time Parsing Tools Natural language time expression parsing. """ from __future__ import annotations import re from datetime import datetime, timedelta from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now, to_utc logger = get_logger() # Constants for time parsing PM_HOUR_OFFSET = 12 DAYS_IN_WEEK = 7 # Date format patterns DATE_FORMATS = [ ("%Y-%m-%d %H:%M:%S", True), ("%Y-%m-%d %H:%M", True), ("%Y-%m-%d", False), ("%Y/%m/%d %H:%M", True), ("%Y/%m/%d", False), ] # Chinese weekday mapping CHINESE_WEEKDAY_MAP = { "一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6, "天": 6, } # English weekday mapping ENGLISH_WEEKDAY_MAP = { "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6, } # Time patterns: (regex_pattern, hour_offset) CHINESE_TIME_PATTERNS = [ (r"下午\s*(\d{1,2})\s*[点::时]?\s*(\d{0,2})", PM_HOUR_OFFSET), (r"晚上\s*(\d{1,2})\s*[点::时]?\s*(\d{0,2})", PM_HOUR_OFFSET), (r"上午\s*(\d{1,2})\s*[点::时]?\s*(\d{0,2})", 0), (r"早上\s*(\d{1,2})\s*[点::时]?\s*(\d{0,2})", 0), (r"中午\s*(\d{1,2})\s*[点::时]?\s*(\d{0,2})", 0), (r"(\d{1,2})\s*[点::时]\s*(\d{0,2})", 0), ] def _parse_iso_format(time_expression: str) -> tuple[datetime | None, bool]: """Try to parse as ISO format.""" try: result = datetime.fromisoformat(time_expression.replace("Z", "+00:00")) time_already_set = "T" in time_expression or " " in time_expression return to_utc(result), time_already_set except ValueError: return None, False def _parse_date_formats(time_expression: str) -> tuple[datetime | None, bool]: """Try common date formats.""" for fmt, has_time in DATE_FORMATS: try: result = datetime.strptime(time_expression, fmt).astimezone() return to_utc(result), has_time except ValueError: continue return None, False def _parse_relative_day(expr: str, now: datetime) -> datetime | None: """Parse relative day expressions like 今天, 明天, 后天.""" if "今天" in expr or "today" in expr: return now.replace(hour=0, minute=0, second=0, microsecond=0) if "明天" in expr or "tomorrow" in expr: return (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) if "后天" in expr: return (now + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0) return None def _parse_days_offset(expr: str, now: datetime) -> datetime | None: """Parse N天后 / in N days patterns.""" days_match = re.search(r"(\d+)\s*天后", expr) or re.search(r"in\s*(\d+)\s*days?", expr) if days_match: days = int(days_match.group(1)) return (now + timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0) return None def _parse_chinese_weekday(expr: str, now: datetime) -> datetime | None: """Parse 下周一/二/.../日 patterns.""" weekday_match = re.search(r"下周([一二三四五六日天])", expr) if weekday_match: target_weekday = CHINESE_WEEKDAY_MAP[weekday_match.group(1)] days_ahead = target_weekday - now.weekday() if days_ahead <= 0: days_ahead += DAYS_IN_WEEK days_ahead += DAYS_IN_WEEK return (now + timedelta(days=days_ahead)).replace(hour=0, minute=0, second=0, microsecond=0) return None def _parse_english_weekday(expr: str, now: datetime) -> datetime | None: """Parse next Monday/Tuesday/etc patterns.""" for day_name, day_num in ENGLISH_WEEKDAY_MAP.items(): if f"next {day_name}" in expr: days_ahead = day_num - now.weekday() if days_ahead <= 0: days_ahead += DAYS_IN_WEEK days_ahead += DAYS_IN_WEEK return (now + timedelta(days=days_ahead)).replace( hour=0, minute=0, second=0, microsecond=0 ) return None def _parse_relative_time(expr: str, now: datetime) -> datetime | None: """Parse all relative time expressions.""" result = _parse_relative_day(expr, now) if result: return result result = _parse_days_offset(expr, now) if result: return result result = _parse_chinese_weekday(expr, now) if result: return result return _parse_english_weekday(expr, now) def _apply_chinese_time(time_expression: str, result: datetime) -> datetime: """Apply Chinese time patterns like 下午3点.""" for pattern, offset in CHINESE_TIME_PATTERNS: match = re.search(pattern, time_expression) if match: hour = int(match.group(1)) minute = int(match.group(2)) if match.group(2) else 0 if offset == PM_HOUR_OFFSET and hour < PM_HOUR_OFFSET: hour += PM_HOUR_OFFSET return result.replace(hour=hour, minute=minute) return result def _apply_english_time(expr: str, result: datetime) -> datetime: """Apply English time patterns like 3pm, 3:30pm.""" en_time_match = re.search(r"(\d{1,2}):?(\d{2})?\s*(am|pm)?", expr, re.IGNORECASE) if en_time_match: hour = int(en_time_match.group(1)) minute = int(en_time_match.group(2)) if en_time_match.group(2) else 0 ampm = en_time_match.group(3) if ampm and ampm.lower() == "pm" and hour < PM_HOUR_OFFSET: hour += PM_HOUR_OFFSET elif ampm and ampm.lower() == "am" and hour == PM_HOUR_OFFSET: hour = 0 return result.replace(hour=hour, minute=minute) return result def _extract_time_of_day(time_expression: str, expr: str, result: datetime) -> datetime: """Extract and apply time of day from expression.""" result = _apply_chinese_time(time_expression, result) return _apply_english_time(expr, result) class TimeTools: """Time parsing tools mixin""" lang: str def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def parse_time(self, time_expression: str) -> str: """Parse natural language time expression to ISO format Args: time_expression: Natural language time like '明天下午3点', 'next Monday', '三天后', '2024-01-20 14:00' Returns: Parsed ISO format datetime or error message """ try: now = get_utc_now() expr = time_expression.lower() time_already_set = False # Try ISO format first result, time_already_set = _parse_iso_format(time_expression) # Try common date formats if not result: result, time_already_set = _parse_date_formats(time_expression) # Try relative time patterns if not result: result = _parse_relative_time(expr, now) # Extract time of day (only if not already set) if result and not time_already_set: result = _extract_time_of_day(time_expression, expr, result) if result: return self._msg("parse_time_success", result=result.isoformat()) return self._msg( "parse_time_failed", expression=time_expression, error="Unrecognized format", ) except Exception as e: logger.error(f"Failed to parse time: {e}") return self._msg("parse_time_failed", expression=time_expression, error=str(e)) ================================================ FILE: lifetrace/llm/agno_tools/tools/todo_tools.py ================================================ """Todo Management Tools CRUD operations for todo items. """ from __future__ import annotations import contextlib from datetime import datetime from typing import TYPE_CHECKING, Any from lifetrace.llm.agno_tools.base import get_message from lifetrace.util.logging_config import get_logger if TYPE_CHECKING: from lifetrace.repositories.sql_todo_repository import SqlTodoRepository logger = get_logger() class TodoTools: """Todo CRUD tools mixin""" lang: str todo_repo: SqlTodoRepository def _msg(self, key: str, **kwargs) -> str: return get_message(self.lang, key, **kwargs) def create_todo( # noqa: PLR0913 self, name: str, description: str | None = None, start_time: str | None = None, end_time: str | None = None, time_zone: str | None = None, deadline: str | None = None, priority: str | None = None, tags: str | None = None, ) -> str: """Create a new todo item Args: name: Todo name/title (required) description: Detailed description (optional) start_time: Start time in ISO format like '2024-01-20T14:00:00' (optional) end_time: End time in ISO format like '2024-01-20T16:00:00' (optional) time_zone: IANA time zone like 'Asia/Shanghai' (optional) deadline: Legacy alias of start_time in ISO format (optional) priority: Priority level - 'high', 'medium', 'low', or 'none' (optional, default: 'none') tags: Comma-separated tags like 'work,urgent' (optional) Returns: Success or failure message """ try: parsed_start_time = None if start_time: with contextlib.suppress(ValueError): parsed_start_time = datetime.fromisoformat(start_time.replace("Z", "+00:00")) elif deadline: with contextlib.suppress(ValueError): parsed_start_time = datetime.fromisoformat(deadline.replace("Z", "+00:00")) parsed_end_time = None if end_time: with contextlib.suppress(ValueError): parsed_end_time = datetime.fromisoformat(end_time.replace("Z", "+00:00")) # Parse tags tag_list = None if tags: tag_list = [t.strip() for t in tags.split(",") if t.strip()] # Normalize priority (handle None and invalid values) valid_priorities = ("high", "medium", "low", "none") normalized_priority = priority if priority in valid_priorities else "none" # Create todo todo_id = self.todo_repo.create( name=name, description=description, start_time=parsed_start_time, end_time=parsed_end_time, time_zone=time_zone, priority=normalized_priority, tags=tag_list, ) if todo_id: return self._msg("create_success", id=todo_id, name=name) else: return self._msg("create_failed", error="Unknown error") except Exception as e: logger.error(f"Failed to create todo: {e}") return self._msg("create_failed", error=str(e)) def complete_todo(self, todo_id: int) -> str: """Mark a todo as completed Args: todo_id: The ID of the todo to complete Returns: Success or failure message """ try: todo = self.todo_repo.get_by_id(todo_id) if not todo: return self._msg("complete_not_found", id=todo_id) success = self.todo_repo.update(todo_id, status="completed") if success: return self._msg("complete_success", id=todo_id) else: return self._msg("complete_failed", error="Update failed") except Exception as e: logger.error(f"Failed to complete todo: {e}") return self._msg("complete_failed", error=str(e)) def update_todo( # noqa: PLR0913, C901 self, todo_id: int, name: str | None = None, description: str | None = None, start_time: str | None = None, end_time: str | None = None, time_zone: str | None = None, deadline: str | None = None, priority: str | None = None, ) -> str: """Update an existing todo Args: todo_id: The ID of the todo to update name: New name (optional) description: New description (optional) start_time: New start time in ISO format (optional) end_time: New end time in ISO format (optional) time_zone: IANA time zone like 'Asia/Shanghai' (optional) deadline: Legacy alias of start_time (optional) priority: New priority - 'high', 'medium', 'low', or 'none' (optional) Returns: Success or failure message """ try: todo = self.todo_repo.get_by_id(todo_id) if not todo: return self._msg("update_not_found", id=todo_id) update_kwargs: dict[str, Any] = {} if name is not None: update_kwargs["name"] = name if description is not None: update_kwargs["description"] = description if start_time is not None: with contextlib.suppress(ValueError): update_kwargs["start_time"] = datetime.fromisoformat( start_time.replace("Z", "+00:00") ) elif deadline is not None: with contextlib.suppress(ValueError): update_kwargs["start_time"] = datetime.fromisoformat( deadline.replace("Z", "+00:00") ) if end_time is not None: with contextlib.suppress(ValueError): update_kwargs["end_time"] = datetime.fromisoformat( end_time.replace("Z", "+00:00") ) if time_zone is not None: update_kwargs["time_zone"] = time_zone if priority is not None and priority in ("high", "medium", "low", "none"): update_kwargs["priority"] = priority if not update_kwargs: return self._msg("update_success", id=todo_id) success = self.todo_repo.update(todo_id, **update_kwargs) if success: return self._msg("update_success", id=todo_id) else: return self._msg("update_failed", error="Update failed") except Exception as e: logger.error(f"Failed to update todo: {e}") return self._msg("update_failed", error=str(e)) def list_todos(self, status: str = "active", limit: int = 10) -> str: """List todos with optional status filter Args: status: Filter by status - 'active', 'completed', 'all' (default: 'active') limit: Maximum number of todos to return (default: 10) Returns: Formatted list of todos or empty message """ try: status_filter = status if status in ("active", "completed") else None todos = self.todo_repo.list_todos(limit=limit, offset=0, status=status_filter) if not todos: return self._msg("list_empty", status=status) result = self._msg("list_header", status=status, count=len(todos)) for todo in todos: item = self._msg( "list_item", id=todo["id"], priority=todo.get("priority", "none"), name=todo["name"], ) start_time = ( todo.get("dtstart") or todo.get("due") or todo.get("start_time") or todo.get("deadline") ) end_time = todo.get("dtend") or todo.get("end_time") if start_time: if isinstance(start_time, datetime): start_label = start_time.strftime("%Y-%m-%d %H:%M") else: start_label = str(start_time) end_label = None if end_time: if isinstance(end_time, datetime): end_label = end_time.strftime("%Y-%m-%d %H:%M") else: end_label = str(end_time) time_label = start_label if end_label: time_label = f"{start_label} ~ {end_label}" item += self._msg("list_item_with_time", time=time_label) result += item + "\n" return result.strip() except Exception as e: logger.error(f"Failed to list todos: {e}") return self._msg("list_empty", status=status) def search_todos(self, keyword: str) -> str: """Search todos by keyword Args: keyword: Search keyword to match against todo name and description Returns: Formatted search results or empty message """ try: all_todos = self.todo_repo.list_todos(limit=200, offset=0, status=None) keyword_lower = keyword.lower() matches = [ todo for todo in all_todos if keyword_lower in todo["name"].lower() or (todo.get("description") and keyword_lower in todo["description"].lower()) ] if not matches: return self._msg("search_empty", keyword=keyword) result = self._msg("search_header", keyword=keyword, count=len(matches)) for todo in matches: result += ( self._msg( "search_item", id=todo["id"], status=todo.get("status", "active"), name=todo["name"], ) + "\n" ) return result.strip() except Exception as e: logger.error(f"Failed to search todos: {e}") return self._msg("search_empty", keyword=keyword) def delete_todo(self, todo_id: int) -> str: """Delete a todo item Args: todo_id: The ID of the todo to delete Returns: Success or failure message """ try: todo = self.todo_repo.get_by_id(todo_id) if not todo: return self._msg("delete_not_found", id=todo_id) success = self.todo_repo.delete(todo_id) if success: return self._msg("delete_success", id=todo_id) else: return self._msg("delete_failed", error="Delete failed") except Exception as e: logger.error(f"Failed to delete todo: {e}") return self._msg("delete_failed", error=str(e)) ================================================ FILE: lifetrace/llm/auto_todo_detection_service.py ================================================ """自动待办检测服务 当白名单应用的截图产生时,自动检测其中的待办事项并创建draft状态的todo """ import json import re from datetime import datetime from typing import Any from lifetrace.llm.llm_client import LLMClient from lifetrace.storage import screenshot_mgr, todo_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.settings import settings from lifetrace.util.time_parser import calculate_scheduled_time from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 默认白名单应用列表(当配置不存在时使用) DEFAULT_WHITELIST_APPS = ["微信", "WeChat", "飞书", "Feishu", "Lark", "钉钉", "DingTalk"] def get_whitelist_apps() -> list[str]: """获取白名单应用列表 优先从配置文件读取,如果配置不存在则使用默认列表 Returns: 白名单应用列表 """ try: apps = settings.get("jobs.auto_todo_detection.params.whitelist.apps") if apps and isinstance(apps, list): return apps except (KeyError, AttributeError): logger.debug("自动待办检测白名单配置不存在,使用默认列表") return DEFAULT_WHITELIST_APPS # 为了向后兼容,保留原有的常量引用(从配置动态读取) TODO_EXTRACTION_WHITELIST_APPS = get_whitelist_apps() class AutoTodoDetectionService: """自动待办检测服务""" def __init__(self): """初始化服务""" self.llm_client = LLMClient() def is_whitelist_app(self, app_name: str) -> bool: """判断是否为白名单应用 Args: app_name: 应用名称 Returns: 是否为白名单应用 """ if not app_name: return False # 每次调用时动态获取白名单,支持配置热更新 whitelist_apps = get_whitelist_apps() app_name_lower = app_name.lower() return any(whitelist_app.lower() in app_name_lower for whitelist_app in whitelist_apps) def detect_and_create_todos_from_screenshot(self, screenshot_id: int) -> dict[str, Any]: """ 检测截图中的待办事项并自动创建draft状态的todo Args: screenshot_id: 截图ID Returns: 包含创建结果的字典: - created_count: 创建的todo数量 - todos: 创建的todo列表 """ try: # 获取截图信息 screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id) if not screenshot: logger.warning(f"截图 {screenshot_id} 不存在") return {"created_count": 0, "todos": []} app_name = screenshot.get("app_name") or "" window_title = screenshot.get("window_title", "") # 检查是否为白名单应用 if not self.is_whitelist_app(app_name): logger.debug(f"截图 {screenshot_id} 的应用 {app_name} 不在白名单中,跳过检测") return {"created_count": 0, "todos": []} # 获取所有active和draft状态的待办 existing_todos = todo_mgr.list_todos(limit=1000, status="active") existing_todos += todo_mgr.list_todos(limit=1000, status="draft") logger.info( f"开始检测截图 {screenshot_id} 的待办事项,已有待办数量: {len(existing_todos)}" ) # 调用视觉模型分析 detection_result = self._call_vision_model( screenshot_id=screenshot_id, existing_todos=existing_todos, app_name=app_name or "", window_title=window_title, ) if not detection_result or not detection_result.get("new_todos"): logger.info(f"截图 {screenshot_id} 未检测到新待办") return {"created_count": 0, "todos": []} # 创建draft状态的todo result = self._create_draft_todos( todos=detection_result["new_todos"], screenshot_id=screenshot_id, app_name=app_name or "", window_title=window_title, ) logger.info( f"截图 {screenshot_id} 检测完成,创建 {result['created_count']} 个draft待办" ) return result except Exception as e: logger.error(f"检测截图 {screenshot_id} 待办失败: {e}", exc_info=True) return {"created_count": 0, "todos": []} def _call_vision_model( self, screenshot_id: int, existing_todos: list[dict[str, Any]], app_name: str, window_title: str, ) -> dict[str, Any]: """ 调用视觉模型分析截图,检测待办事项 Args: screenshot_id: 截图ID existing_todos: 已有待办列表(用于去重) app_name: 应用名称 window_title: 窗口标题 Returns: 检测结果字典,包含new_todos列表 """ _ = app_name _ = window_title if not self.llm_client.is_available(): logger.warning("LLM客户端不可用,无法检测待办") return {"new_todos": []} try: # 格式化已有待办列表为JSON existing_todos_json = json.dumps( [ { "id": todo.get("id"), "name": todo.get("name"), "description": todo.get("description"), } for todo in existing_todos[:50] # 限制数量,避免prompt过长 ], ensure_ascii=False, indent=2, ) # 从配置文件加载提示词 system_prompt = get_prompt("auto_todo_detection", "system_assistant") user_prompt = get_prompt( "auto_todo_detection", "user_prompt", existing_todos_json=existing_todos_json, ) # 构建完整的提示词 full_prompt = f"{system_prompt}\n\n{user_prompt}" # 调用视觉模型 result = self.llm_client.vision_chat( screenshot_ids=[screenshot_id], prompt=full_prompt, temperature=0.3, # 使用较低温度以提高准确性 max_tokens=2000, ) response_text = result.get("response", "") if not response_text: logger.warning("视觉模型返回空响应") return {"new_todos": []} # 解析LLM响应 detection_result = self._parse_llm_response(response_text) return detection_result except Exception as e: logger.error(f"调用视觉模型检测待办失败: {e}", exc_info=True) return {"new_todos": []} def _parse_llm_response(self, response_text: str) -> dict[str, Any]: """ 解析LLM响应为检测结果 Args: response_text: LLM返回的文本 Returns: 包含new_todos列表的字典 """ try: # 尝试提取JSON json_match = re.search(r"\{.*\}", response_text, re.DOTALL) if json_match: json_str = json_match.group(0) result = json.loads(json_str) if "new_todos" in result: return result # 如果没有找到JSON,尝试直接解析整个响应 result = json.loads(response_text) if "new_todos" in result: return result logger.warning("LLM响应格式不正确,未找到new_todos字段") return {"new_todos": []} except json.JSONDecodeError as e: logger.error(f"解析LLM响应JSON失败: {e}, 响应内容: {response_text[:200]}") return {"new_todos": []} except Exception as e: logger.error(f"解析LLM响应失败: {e}", exc_info=True) return {"new_todos": []} def _build_user_notes( self, screenshot_id: int, app_name: str, window_title: str, source_text: str, time_info: dict[str, Any], confidence: float | None, ) -> str: """构建user_notes,记录来源信息""" user_notes_parts = [ f"来源截图ID: {screenshot_id}", f"应用: {app_name}", ] if window_title: user_notes_parts.append(f"窗口: {window_title}") if source_text: user_notes_parts.append(f"来源文本: {source_text}") if time_info.get("raw_text"): user_notes_parts.append(f"时间: {time_info.get('raw_text')}") if confidence is not None: user_notes_parts.append(f"置信度: {confidence:.2%}") return "\n".join(user_notes_parts) def _calculate_todo_scheduled_time(self, time_info: dict[str, Any]) -> datetime | None: """计算todo的scheduled_time""" if not time_info: return None try: reference_time = get_utc_now() return calculate_scheduled_time(time_info, reference_time) except Exception as e: logger.warning(f"计算scheduled_time失败: {e}") return None def _create_single_draft_todo( self, todo_data: dict[str, Any], screenshot_id: int, app_name: str, window_title: str, ) -> dict[str, Any] | None: """创建单个draft状态的todo""" title = todo_data.get("title", "").strip() if not title: logger.warning("跳过标题为空的待办") return None description = todo_data.get("description") if description: description = description.strip() source_text = todo_data.get("source_text", "") time_info = todo_data.get("time_info", {}) confidence = todo_data.get("confidence") scheduled_time = self._calculate_todo_scheduled_time(time_info) user_notes = self._build_user_notes( screenshot_id, app_name, window_title, source_text, time_info, confidence ) todo_id = todo_mgr.create_todo( name=title, description=description, user_notes=user_notes, start_time=scheduled_time, status="draft", # 关键:创建为draft状态 priority="none", tags=["自动提取"], ) if todo_id: logger.info(f"创建draft待办: {todo_id} - {title}") return { "id": todo_id, "name": title, "scheduled_time": scheduled_time.isoformat() if scheduled_time else None, } logger.warning(f"创建待办失败: {title}") return None def _create_draft_todos( self, todos: list[dict[str, Any]], screenshot_id: int, app_name: str, window_title: str, ) -> dict[str, Any]: """ 创建draft状态的todo Args: todos: 检测到的待办列表 screenshot_id: 截图ID app_name: 应用名称 window_title: 窗口标题 Returns: 创建结果统计 """ created_todos = [] created_count = 0 for todo_data in todos: try: result = self._create_single_draft_todo( todo_data, screenshot_id, app_name, window_title ) if result: created_count += 1 created_todos.append(result) except Exception as e: logger.error(f"处理待办数据失败: {e}, 数据: {todo_data}", exc_info=True) continue return { "created_count": created_count, "created_todos": created_todos, } ================================================ FILE: lifetrace/llm/context_builder.py ================================================ import contextlib import json from datetime import datetime from typing import Any from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 常量定义 MAX_RECORDS_PER_APP = 5 # 每个应用最多显示的记录数 MAX_SEARCH_RESULTS = 10 # 搜索结果最大显示数量 MAX_APP_STATS = 10 # 应用统计最大显示数量 OCR_TEXT_SUMMARY_LIMIT = 200 # 总结模式下 OCR 文本截断长度 OCR_TEXT_SEARCH_LIMIT = 150 # 搜索模式下 OCR 文本截断长度 OCR_TEXT_TRUNCATE_LIMIT = 100 # 截断模式下 OCR 文本长度 class ContextBuilder: """上下文构建器,将检索到的数据整理成适合LLM处理的格式""" def __init__(self, max_context_length: int = 8000): """ 初始化上下文构建器 Args: max_context_length: 最大上下文长度(字符数) """ self.max_context_length = max_context_length logger.info(f"上下文构建器初始化完成,最大长度: {max_context_length}") def build_context( self, query: str, retrieved_data: list[dict[str, Any]], query_type: str = "search", ) -> dict[str, Any]: """ 构建完整的上下文 Args: query: 用户原始查询 retrieved_data: 检索到的数据 query_type: 查询类型 Returns: 构建好的上下文字典 """ context = { "query": query, "query_type": query_type, "data_summary": self._build_data_summary(retrieved_data), "detailed_records": self._build_detailed_records(retrieved_data), "metadata": self._build_metadata(retrieved_data), } # 检查并截断过长的上下文 context = self._truncate_context(context) logger.info(f"上下文构建完成,包含 {len(retrieved_data)} 条记录") return context def _format_timestamp(self, timestamp: str) -> str: """格式化时间戳""" if not timestamp or timestamp == "未知时间": return "未知时间" try: dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) return dt.strftime("%Y-%m-%d %H:%M") except Exception: return timestamp def _format_record_for_summary( self, index: int, record: dict[str, Any], text_limit: int ) -> str: """格式化单条记录用于总结""" timestamp = self._format_timestamp(record.get("timestamp", "未知时间")) ocr_text = record.get("ocr_text", "无文本内容") window_title = record.get("window_title", "") screenshot_id = record.get("screenshot_id") or record.get("id") if len(ocr_text) > text_limit: ocr_text = ocr_text[:text_limit] + "..." record_text = f"{index + 1}. 时间: {timestamp}" if window_title: record_text += f", 窗口: {window_title}" if screenshot_id: record_text += f", 截图ID: {screenshot_id}" record_text += f"\n 内容: {ocr_text}" return record_text def build_summary_context(self, query: str, retrieved_data: list[dict[str, Any]]) -> str: """ 构建用于总结的上下文文本 Args: query: 用户查询 retrieved_data: 检索到的数据 Returns: 格式化的上下文文本 """ if not retrieved_data: return "没有找到相关的历史记录数据。" context_parts = [ get_prompt("context_builder", "data_analysis_base"), "", get_prompt("context_builder", "citation_requirements"), "", get_prompt("context_builder", "response_format"), "", f"用户查询: {query}", f"找到 {len(retrieved_data)} 条相关记录:", "", ] # 按应用分组 app_groups = self._group_by_app(retrieved_data) for app_name, records in app_groups.items(): context_parts.append(f"=== {app_name} ({len(records)} 条记录) ===") for i, record in enumerate(records[:MAX_RECORDS_PER_APP]): record_text = self._format_record_for_summary(i, record, OCR_TEXT_SUMMARY_LIMIT) context_parts.append(record_text) if len(records) > MAX_RECORDS_PER_APP: context_parts.append(f" ... 还有 {len(records) - MAX_RECORDS_PER_APP} 条记录") context_parts.append("") context_text = "\n".join(context_parts) # 检查长度并截断 if len(context_text) > self.max_context_length: context_text = context_text[: self.max_context_length] + "\n\n[内容过长,已截断]" return context_text def build_search_context(self, query: str, retrieved_data: list[dict[str, Any]]) -> str: """ 构建用于搜索的上下文文本 Args: query: 用户查询 retrieved_data: 检索到的数据 Returns: 格式化的上下文文本 """ if not retrieved_data: return f"查询: {query}\n\n未找到相关记录。" context_parts = [ get_prompt("context_builder", "data_analysis_base"), "", get_prompt("context_builder", "citation_requirements"), "", get_prompt("context_builder", "response_format"), "", f"搜索查询: {query}", f"找到 {len(retrieved_data)} 条匹配结果:", "", ] # 按相关性排序显示 sorted_data = sorted( retrieved_data, key=lambda x: x.get("relevance_score", 0), reverse=True ) for i, record in enumerate(sorted_data[:10]): # 最多显示10条 timestamp = record.get("timestamp", "未知时间") if timestamp and timestamp != "未知时间": with contextlib.suppress(ValueError, TypeError): dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) timestamp = dt.strftime("%Y-%m-%d %H:%M") app_name = record.get("app_name", "未知应用") ocr_text = record.get("ocr_text", "无文本内容") relevance = record.get("relevance_score", 0) screenshot_id = record.get("screenshot_id") or record.get("id") # 获取截图ID # 截断过长的文本 if len(ocr_text) > OCR_TEXT_SEARCH_LIMIT: ocr_text = ocr_text[:OCR_TEXT_SEARCH_LIMIT] + "..." # 构建包含截图ID的上下文信息 id_info = f" (截图ID: {screenshot_id})" if screenshot_id else "" context_parts.append( f"{i + 1}. [{app_name}] {timestamp} (相关性: {relevance:.2f}){id_info}\n {ocr_text}" ) context_text = "\n\n".join(context_parts) # 检查长度并截断 if len(context_text) > self.max_context_length: context_text = context_text[: self.max_context_length] + "\n\n[搜索结果过长,已截断]" return context_text def _build_app_distribution_context( self, app_distribution: dict[str, int], total_count: int ) -> list[str]: """构建应用分布上下文""" if not app_distribution: return [] parts = ["\n应用分布:"] sorted_apps = sorted(app_distribution.items(), key=lambda x: x[1], reverse=True) for app, count in sorted_apps[:MAX_APP_STATS]: percentage = (count / total_count * 100) if total_count > 0 else 0 parts.append(f" {app}: {count} 条 ({percentage:.1f}%)") return parts def _build_time_range_context(self, time_range: dict[str, Any]) -> list[str]: """构建时间范围上下文""" if not time_range.get("earliest") or not time_range.get("latest"): return [] try: earliest = datetime.fromisoformat(time_range["earliest"].replace("Z", "+00:00")) latest = datetime.fromisoformat(time_range["latest"].replace("Z", "+00:00")) return [ f"\n时间范围: {earliest.strftime('%Y-%m-%d %H:%M')} 至 {latest.strftime('%Y-%m-%d %H:%M')}" ] except Exception: return [f"\n时间范围: {time_range['earliest']} 至 {time_range['latest']}"] def _build_query_conditions_context(self, query_conditions: Any) -> list[str]: """构建查询条件上下文""" parts: list[str] = [] # 从对象或字典中获取字段 def get_field(obj: Any, field: str) -> Any: if hasattr(obj, field): return getattr(obj, field) if isinstance(obj, dict): return obj.get(field) return None app_names = get_field(query_conditions, "app_names") keywords = get_field(query_conditions, "keywords") start_date = get_field(query_conditions, "start_date") end_date = get_field(query_conditions, "end_date") if not (app_names or keywords or start_date or end_date): return parts parts.append("\n查询条件:") if app_names: if isinstance(app_names, list): parts.append(f" 应用: {', '.join(app_names)}") else: parts.append(f" 应用: {app_names}") if keywords: parts.append(f" 关键词: {', '.join(keywords)}") if start_date: parts.append(f" 开始时间: {start_date}") if end_date: parts.append(f" 结束时间: {end_date}") return parts def build_statistics_context( self, query: str, retrieved_data: list[dict[str, Any]], stats: dict[str, Any] ) -> str: """ 构建用于统计的上下文文本 Args: query: 用户查询 retrieved_data: 检索到的数据 stats: 统计信息 Returns: 格式化的上下文文本 """ _ = retrieved_data context_parts = [ get_prompt("context_builder", "data_analysis_base"), "", get_prompt("context_builder", "citation_requirements"), "", get_prompt("context_builder", "response_format"), "", f"统计查询: {query}", "", ] # 基础统计 total_count = stats.get("total_screenshots", 0) context_parts.append(f"总记录数: {total_count}") # 应用分布 context_parts.extend( self._build_app_distribution_context(stats.get("app_distribution", {}), total_count) ) # 时间范围 context_parts.extend(self._build_time_range_context(stats.get("time_range", {}))) # 查询条件 context_parts.extend( self._build_query_conditions_context(stats.get("query_conditions", {})) ) return "\n".join(context_parts) def _build_data_summary(self, retrieved_data: list[dict[str, Any]]) -> dict[str, Any]: """构建数据摘要""" if not retrieved_data: return {"total_count": 0, "app_distribution": {}, "time_span": None} # 应用分布 app_counts = {} timestamps = [] for record in retrieved_data: app_name = record.get("app_name", "未知应用") app_counts[app_name] = app_counts.get(app_name, 0) + 1 timestamp = record.get("timestamp") if timestamp: timestamps.append(timestamp) # 时间跨度 time_span = None if timestamps: timestamps.sort() time_span = {"earliest": timestamps[0], "latest": timestamps[-1]} return { "total_count": len(retrieved_data), "app_distribution": app_counts, "time_span": time_span, } def _build_detailed_records(self, retrieved_data: list[dict[str, Any]]) -> list[dict[str, Any]]: """构建详细记录""" detailed_records = [] for record in retrieved_data[:20]: # 最多保留20条详细记录 detailed_record = { "timestamp": record.get("timestamp"), "app_name": record.get("app_name"), "window_title": record.get("window_title"), "ocr_text": record.get("ocr_text", "")[:500], # 截断OCR文本 "relevance_score": record.get("relevance_score", 0), "screenshot_id": record.get("screenshot_id") or record.get("id"), # 添加截图ID } detailed_records.append(detailed_record) return detailed_records def _build_metadata(self, retrieved_data: list[dict[str, Any]]) -> dict[str, Any]: """构建元数据""" return { "total_retrieved": len(retrieved_data), "build_time": get_utc_now().isoformat(), "context_version": "1.0", } def _group_by_app( self, retrieved_data: list[dict[str, Any]] ) -> dict[str, list[dict[str, Any]]]: """按应用分组""" app_groups = {} for record in retrieved_data: app_name = record.get("app_name", "未知应用") if app_name not in app_groups: app_groups[app_name] = [] app_groups[app_name].append(record) # 按记录数量排序 return dict(sorted(app_groups.items(), key=lambda x: len(x[1]), reverse=True)) def _truncate_context(self, context: dict[str, Any]) -> dict[str, Any]: """截断过长的上下文""" context_str = json.dumps(context, ensure_ascii=False) if len(context_str) <= self.max_context_length: return context # 逐步减少详细记录 detailed_records = context.get("detailed_records", []) while ( len(json.dumps(context, ensure_ascii=False)) > self.max_context_length and detailed_records ): detailed_records.pop() context["detailed_records"] = detailed_records # 如果还是太长,截断OCR文本 for record in context.get("detailed_records", []): if "ocr_text" in record and len(record["ocr_text"]) > OCR_TEXT_TRUNCATE_LIMIT: record["ocr_text"] = record["ocr_text"][:OCR_TEXT_TRUNCATE_LIMIT] + "..." logger.warning(f"上下文过长,已截断至 {len(json.dumps(context, ensure_ascii=False))} 字符") return context ================================================ FILE: lifetrace/llm/event_summary_clustering.py ================================================ """ 事件摘要聚类模块 包含HDBSCAN聚类相关逻辑 """ from lifetrace.util.logging_config import get_logger from .event_summary_config import ( HDBSCAN_AVAILABLE, MIN_CLUSTER_SIZE, MIN_TEXT_COUNT_FOR_CLUSTERING, SCIPY_AVAILABLE, pdist, squareform, ) logger = get_logger() try: import hdbscan import numpy as np except ImportError: hdbscan = None np = None def check_clustering_prerequisites(ocr_texts: list[str], vector_service) -> tuple[bool, str]: """检查聚类前置条件 Returns: (是否满足条件, 错误消息) """ if not HDBSCAN_AVAILABLE: return False, "HDBSCAN不可用,回退到简单聚合" if not ocr_texts or len(ocr_texts) < MIN_TEXT_COUNT_FOR_CLUSTERING: return False, "文本数量不足" if not vector_service: return False, "向量服务未初始化,回退到简单聚合" if not vector_service.is_enabled(): return ( False, f"向量服务未启用 (enabled={vector_service.enabled}, " f"vector_db={'存在' if vector_service.vector_db else '不存在'}),回退到简单聚合", ) if not vector_service.vector_db: return False, "向量数据库实例不存在,回退到简单聚合" return True, "" def vectorize_texts(ocr_texts: list[str], vector_service) -> tuple[list[list[float]], list[str]]: """对OCR文本进行向量化 Returns: (向量列表, 有效文本列表) """ embeddings = [] valid_texts = [] for text in ocr_texts: if not text or not text.strip(): continue embedding = vector_service.vector_db.embed_text(text) if embedding: embeddings.append(embedding) valid_texts.append(text) return embeddings, valid_texts def calculate_cluster_params(text_count: int) -> int: """计算HDBSCAN聚类参数 适应行级别的文本数量(通常远大于截图数量),使用更保守的参数。 Args: text_count: 文本数量(对于行级别聚类,通常是文本行数量) Returns: min_cluster_size """ min_cluster_size = max(MIN_CLUSTER_SIZE, text_count // 20) max_cluster_size = max(MIN_CLUSTER_SIZE, text_count // 3) min_cluster_size = min(min_cluster_size, max_cluster_size) return max(MIN_CLUSTER_SIZE, min_cluster_size) def select_representative_texts(cluster_labels: list[int], valid_texts: list[str]) -> list[str]: """从聚类结果中选择代表性文本 Returns: 代表性文本列表 """ representative_texts = [] unique_labels = set(cluster_labels) for label in unique_labels: indices = [ idx for idx, cluster_label in enumerate(cluster_labels) if cluster_label == label ] if not indices: continue cluster_texts = [valid_texts[i] for i in indices] longest_text = max(cluster_texts, key=len) representative_texts.append(longest_text) return representative_texts def cluster_ocr_texts_with_hdbscan(ocr_texts: list[str], vector_service) -> list[str]: """ 使用HDBSCAN对向量化的OCR文本进行聚类,返回代表性文本 """ can_cluster, error_msg = check_clustering_prerequisites(ocr_texts, vector_service) if not can_cluster: if error_msg and error_msg != "文本数量不足": logger.warning(error_msg) return ocr_texts try: if hdbscan is None or np is None: logger.warning("HDBSCAN 或 numpy 未安装,回退到简单聚合") return ocr_texts embeddings, valid_texts = vectorize_texts(ocr_texts, vector_service) if len(embeddings) < MIN_TEXT_COUNT_FOR_CLUSTERING: logger.debug("有效文本数量不足,无法进行聚类") return valid_texts embeddings_array = np.array(embeddings) min_cluster_size = calculate_cluster_params(len(valid_texts)) logger.info( f"使用HDBSCAN聚类: {len(valid_texts)} 个文本, min_cluster_size={min_cluster_size}" ) if SCIPY_AVAILABLE and pdist is not None and squareform is not None: cosine_distances = pdist(embeddings_array, metric="cosine") distance_matrix = squareform(cosine_distances) clusterer = hdbscan.HDBSCAN( min_cluster_size=min_cluster_size, min_samples=1, metric="precomputed", ) cluster_labels = clusterer.fit_predict(distance_matrix).tolist() else: logger.warning("scipy不可用,使用欧氏距离替代余弦距离") clusterer = hdbscan.HDBSCAN( min_cluster_size=min_cluster_size, min_samples=1, metric="euclidean", ) cluster_labels = clusterer.fit_predict(embeddings_array).tolist() representative_texts = select_representative_texts(cluster_labels, valid_texts) return representative_texts or valid_texts except Exception as e: logger.error(f"HDBSCAN聚类失败: {e}", exc_info=True) return ocr_texts ================================================ FILE: lifetrace/llm/event_summary_config.py ================================================ """ 事件摘要服务配置模块 包含常量定义和可选依赖检查 """ from lifetrace.util.logging_config import get_logger logger = get_logger() # 常量定义 MIN_SCREENSHOTS_FOR_LLM = 3 # 使用LLM生成摘要的最小截图数量 MIN_OCR_TEXT_LENGTH = 10 # OCR文本的最小长度阈值 MAX_COMBINED_TEXT_LENGTH = 3000 # 合并OCR文本的最大长度 MIN_CLUSTER_SIZE = 2 # HDBSCAN聚类的最小聚类大小 MIN_TEXT_COUNT_FOR_CLUSTERING = 2 # 进行聚类的最小文本数量 MIN_OCR_LINE_LENGTH = 3 # OCR文本行的最小长度阈值(用于过滤噪声行) MIN_OCR_CONFIDENCE = 0.6 # OCR结果最低置信度,低于此阈值的块跳过 UI_REPEAT_THRESHOLD = 3 # 将文本标记为UI候选的跨截图重复次数阈值 UI_CANDIDATE_MAX_LENGTH = 25 # UI候选的最大长度(字符) UI_REPRESENTATIVE_LIMIT = 2 # 保留的代表性UI文本数量上限 MAX_TITLE_LENGTH = 20 # 标题最大长度(字符数) MAX_SUMMARY_LENGTH = 50 # 摘要最大长度(字符数,对应提示词要求) OCR_PREVIEW_LENGTH = 100 # OCR预览文本长度 RESPONSE_PREVIEW_LENGTH = 500 # 响应预览文本长度 # 尝试导入HDBSCAN try: import hdbscan # noqa: F401 import numpy as np # noqa: F401 from scipy.spatial.distance import pdist, squareform HDBSCAN_AVAILABLE = True SCIPY_AVAILABLE = True except ImportError: HDBSCAN_AVAILABLE = False SCIPY_AVAILABLE = False pdist = None squareform = None logger.warning("HDBSCAN or scipy not available, clustering will fallback to simple aggregation") ================================================ FILE: lifetrace/llm/event_summary_ocr.py ================================================ """ 事件摘要OCR文本处理模块 包含OCR文本提取、过滤和UI候选分离逻辑 """ import re from typing import Any from lifetrace.storage import get_session from lifetrace.storage.models import OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from .event_summary_config import ( MIN_OCR_CONFIDENCE, MIN_OCR_LINE_LENGTH, UI_CANDIDATE_MAX_LENGTH, UI_REPEAT_THRESHOLD, UI_REPRESENTATIVE_LIMIT, ) logger = get_logger() def should_filter_line(line: str, debug_info: dict[str, Any]) -> bool: """判断是否应该过滤掉某行文本 Returns: True表示应该过滤,False表示保留 """ if not line: return True debug_info["raw_lines_count"] += 1 if len(line) < MIN_OCR_LINE_LENGTH: debug_info["filtered_short_count"] += 1 return True if line.isdigit() or re.fullmatch(r"[^\w\s]+", line): debug_info["filtered_symbol_or_digit_count"] += 1 return True return False def process_ocr_block( ocr_block: str, screenshot_id: int, ocr_lines: list[str], lines_with_meta: list[dict[str, Any]], debug_info: dict[str, Any], ) -> None: """处理单个OCR块,提取并过滤文本行""" lines = ocr_block.split("\n") for raw_line in lines: line = raw_line.strip() if should_filter_line(line, debug_info): continue ocr_lines.append(line) lines_with_meta.append({"text": line, "screenshot_id": screenshot_id}) def get_event_ocr_texts(event_id: int) -> tuple[list[str], dict[str, Any]]: """获取事件下所有截图的OCR文本行 将OCR文本按换行符分割成行(同一水平分组的bounding boxes合并后的文本), 然后对每行进行聚类。 Args: event_id: 事件ID Returns: (文本行列表, 调试信息字典) """ ocr_lines = [] original_ocr_blocks = [] lines_with_meta: list[dict[str, Any]] = [] debug_info = { "original_ocr_blocks": [], "original_ocr_blocks_count": 0, "ocr_lines_count": 0, "lines_per_block_avg": 0.0, "raw_lines_count": 0, "filtered_short_count": 0, "filtered_symbol_or_digit_count": 0, "filtered_low_confidence_blocks": 0, "lines_with_meta": [], } try: with get_session() as session: screenshots = ( session.query(Screenshot).filter(col(Screenshot.event_id) == event_id).all() ) for screenshot in screenshots: ocr_results = ( session.query(OCRResult) .filter(col(OCRResult.screenshot_id) == screenshot.id) .all() ) for ocr in ocr_results: if not ocr.text_content or not ocr.text_content.strip(): continue ocr_block = ocr.text_content.strip() original_ocr_blocks.append(ocr_block) if ocr.confidence is not None and ocr.confidence < MIN_OCR_CONFIDENCE: debug_info["filtered_low_confidence_blocks"] += 1 continue process_ocr_block( ocr_block, screenshot.id, ocr_lines, lines_with_meta, debug_info ) debug_info["original_ocr_blocks"] = original_ocr_blocks debug_info["original_ocr_blocks_count"] = len(original_ocr_blocks) debug_info["ocr_lines_count"] = len(ocr_lines) debug_info["lines_with_meta"] = lines_with_meta if len(original_ocr_blocks) > 0: debug_info["lines_per_block_avg"] = len(ocr_lines) / len(original_ocr_blocks) return ocr_lines, debug_info except Exception as e: logger.error(f"获取事件OCR文本失败: {e}") return [], debug_info def build_text_to_screenshots_map(lines_with_meta: list[dict[str, Any]]) -> dict[str, set[int]]: """构建文本到截图ID集合的映射""" text_to_screenshots: dict[str, set[int]] = {} for item in lines_with_meta: text = item.get("text") screenshot_id = item.get("screenshot_id") if not text: continue if text not in text_to_screenshots: text_to_screenshots[text] = set() screenshot_id = screenshot_id if screenshot_id is not None else -1 text_to_screenshots[text].add(screenshot_id) return text_to_screenshots def identify_ui_candidates(text_to_screenshots: dict[str, set[int]]) -> set[str]: """识别UI候选文本""" return { text for text, screenshots in text_to_screenshots.items() if len(screenshots) >= UI_REPEAT_THRESHOLD and len(text) <= UI_CANDIDATE_MAX_LENGTH } def separate_ui_and_body_lines( lines_with_meta: list[dict[str, Any]], ui_candidates: set[str] ) -> tuple[list[str], list[str]]: """将行分为UI行和正文行""" ui_lines: list[str] = [] body_lines: list[str] = [] for item in lines_with_meta: text = item.get("text") if not text: continue if text in ui_candidates: ui_lines.append(text) else: body_lines.append(text) return ui_lines, body_lines def select_representative_ui_texts(ui_lines: list[str]) -> list[str]: """选择代表性UI文本(去重)""" seen_ui: set[str] = set() ui_kept: list[str] = [] for line in ui_lines: if line in seen_ui: continue ui_kept.append(line) seen_ui.add(line) if len(ui_kept) >= UI_REPRESENTATIVE_LIMIT: break return ui_kept def separate_ui_candidates( lines_with_meta: list[dict[str, Any]], ) -> tuple[list[str], dict[str, Any]]: """识别跨截图重复的UI候选文本,并返回正文行 Args: lines_with_meta: 包含文本及其来源截图ID的行级元数据 Returns: (正文行列表, ui调试信息) """ ui_info = { "ui_candidates": [], "ui_candidates_count": 0, "ui_lines_total": 0, "ui_kept": [], "body_lines_count": 0, "repeat_threshold": UI_REPEAT_THRESHOLD, "length_cutoff": UI_CANDIDATE_MAX_LENGTH, } if not lines_with_meta: return [], ui_info text_to_screenshots = build_text_to_screenshots_map(lines_with_meta) ui_candidates = identify_ui_candidates(text_to_screenshots) ui_lines, body_lines = separate_ui_and_body_lines(lines_with_meta, ui_candidates) ui_kept = select_representative_ui_texts(ui_lines) ui_info.update( { "ui_candidates": list(ui_candidates), "ui_candidates_count": len(ui_candidates), "ui_lines_total": len(ui_lines), "ui_kept": ui_kept, "body_lines_count": len(body_lines), } ) return body_lines, ui_info ================================================ FILE: lifetrace/llm/event_summary_service.py ================================================ """ 事件摘要生成服务 使用LLM为事件生成标题和摘要 """ import json import threading from datetime import datetime from typing import Any from lifetrace.core.dependencies import get_vector_service from lifetrace.llm.llm_client import LLMClient from lifetrace.storage import event_mgr, get_session from lifetrace.storage.models import Event from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.token_usage_logger import log_token_usage from .event_summary_clustering import cluster_ocr_texts_with_hdbscan from .event_summary_config import ( MAX_COMBINED_TEXT_LENGTH, MAX_SUMMARY_LENGTH, MAX_TITLE_LENGTH, MIN_OCR_TEXT_LENGTH, MIN_SCREENSHOTS_FOR_LLM, OCR_PREVIEW_LENGTH, ) from .event_summary_ocr import get_event_ocr_texts, separate_ui_candidates logger = get_logger() class EventSummaryService: """事件摘要生成服务""" def __init__(self, vector_service=None): """初始化服务 Args: vector_service: 向量服务实例(可选),如果未提供则尝试从dependencies导入 """ self.llm_client = LLMClient() self.vector_service = vector_service def _get_vector_service(self): """动态获取向量服务实例""" if self.vector_service is not None: logger.debug("使用初始化时提供的vector_service") return self.vector_service try: vector_svc = get_vector_service() if vector_svc is not None: logger.info( f"从core.dependencies获取到vector_service: " f"enabled={vector_svc.enabled}, " f"vector_db={'存在' if vector_svc.vector_db else '不存在'}" ) return vector_svc else: logger.warning("get_vector_service()返回None,可能还未初始化") return None except ImportError as e: logger.warning(f"无法导入core.dependencies模块: {e}") return None except Exception as e: logger.warning(f"获取vector_service时出错: {e}") return None def _process_event_with_few_screenshots( self, event_id: int, event_info: dict[str, Any], screenshot_count: int ) -> dict[str, Any]: """处理截图数量较少的事件""" logger.info(f"事件 {event_id} 只有 {screenshot_count} 张截图,使用fallback summary") ocr_lines, ocr_debug_info = get_event_ocr_texts(event_id) result = self._generate_fallback_summary( app_name=event_info["app_name"], window_title=event_info["window_title"], ) return { "result": result, "ocr_lines": ocr_lines, "ocr_debug_info": ocr_debug_info, "clustering_info": None, "llm_info": None, } def _process_event_with_sufficient_screenshots( self, event_id: int, event_info: dict[str, Any] ) -> dict[str, Any]: """处理有足够截图的事件""" ocr_lines, ocr_debug_info = get_event_ocr_texts(event_id) body_lines, ui_info = separate_ui_candidates(ocr_debug_info.get("lines_with_meta", [])) ocr_debug_info["ui_info"] = ui_info effective_lines = body_lines if body_lines else ocr_lines combined_ocr_length = len("".join(effective_lines).strip()) if effective_lines else 0 clustering_info = None llm_info = None if effective_lines and combined_ocr_length > MIN_OCR_TEXT_LENGTH: vector_service = self._get_vector_service() clustered_texts = cluster_ocr_texts_with_hdbscan(effective_lines, vector_service) clustering_info = None if not clustered_texts: clustered_texts = effective_lines ui_kept = ui_info.get("ui_kept", []) if ui_info else [] llm_input_texts = clustered_texts + ui_kept if ui_kept else clustered_texts result = self._generate_summary_with_llm( ocr_texts=llm_input_texts, app_name=event_info["app_name"], window_title=event_info["window_title"], start_time=event_info["start_time"], end_time=event_info["end_time"], ) llm_info = None else: result = self._generate_fallback_summary( app_name=event_info["app_name"], window_title=event_info["window_title"], ) return { "result": result, "ocr_lines": ocr_lines, "ocr_debug_info": ocr_debug_info, "clustering_info": clustering_info, "llm_info": llm_info, } def _update_event_summary_in_db(self, event_id: int, result: dict[str, str] | None) -> bool: """更新数据库中的事件摘要""" if not result: logger.error(f"事件 {event_id} 摘要生成失败") return False success = event_mgr.update_event_summary( event_id=event_id, ai_title=result["title"], ai_summary=result["summary"], ) if success: logger.info(f"事件 {event_id} 摘要生成成功: {result['title']}") return True logger.error(f"事件 {event_id} 摘要更新失败") return False def generate_event_summary(self, event_id: int) -> bool: """为单个事件生成摘要 Args: event_id: 事件ID Returns: 生成是否成功 """ event_info = None try: event_info = self._get_event_info(event_id) if not event_info: logger.warning(f"事件 {event_id} 不存在") return False screenshots = event_mgr.get_event_screenshots(event_id) screenshot_count = len(screenshots) if screenshot_count < MIN_SCREENSHOTS_FOR_LLM: process_result = self._process_event_with_few_screenshots( event_id, event_info, screenshot_count ) result = process_result["result"] else: process_result = self._process_event_with_sufficient_screenshots( event_id, event_info ) result = process_result["result"] return self._update_event_summary_in_db(event_id, result) except Exception as e: logger.error(f"生成事件 {event_id} 摘要时出错: {e}", exc_info=True) return False def _get_event_info(self, event_id: int) -> dict[str, Any] | None: """获取事件信息""" try: with get_session() as session: event = session.query(Event).filter(col(Event.id) == event_id).first() if not event: return None return { "id": event.id, "app_name": event.app_name, "window_title": event.window_title, "start_time": event.start_time, "end_time": event.end_time, } except Exception as e: logger.error(f"获取事件信息失败: {e}") return None def _prepare_ocr_text(self, ocr_texts: list[str]) -> str | None: """准备OCR文本,合并并限制长度""" combined_text = "\n".join(ocr_texts) if len(combined_text) > MAX_COMBINED_TEXT_LENGTH: combined_text = combined_text[:MAX_COMBINED_TEXT_LENGTH] + "..." if not combined_text or len(combined_text.strip()) < MIN_OCR_TEXT_LENGTH: return None return combined_text def _extract_json_from_response(self, content: str) -> tuple[str, str]: """从LLM响应中提取JSON内容""" original_content = content if "```json" in content: json_start = content.find("```json") + 7 json_end = content.find("```", json_start) content = content[json_start:json_end].strip() elif "```" in content: json_start = content.find("```") + 3 json_end = content.find("```", json_start) content = content[json_start:json_end].strip() return content, original_content def _parse_llm_response(self, content: str, original_content: str) -> dict[str, str] | None: """解析LLM响应为字典""" try: result = json.loads(content) if "title" in result and "summary" in result: title = result["title"][:MAX_TITLE_LENGTH] summary = result["summary"][:MAX_SUMMARY_LENGTH] return {"title": title, "summary": summary} logger.warning(f"LLM返回格式不正确: {result}") return None except json.JSONDecodeError as e: ocr_preview = ( original_content[:OCR_PREVIEW_LENGTH] if len(original_content) > OCR_PREVIEW_LENGTH else original_content ) logger.error(f"解析LLM响应JSON失败: {e}\n原始响应: {ocr_preview[:200]}") return None def _generate_summary_with_llm( self, ocr_texts: list[str], app_name: str, window_title: str, start_time: datetime, end_time: datetime | None, ) -> dict[str, str] | None: """使用LLM生成标题和摘要""" if not self.llm_client.is_available(): logger.warning("LLM客户端不可用,使用后备方案") return self._generate_fallback_summary(app_name, window_title) combined_text = self._prepare_ocr_text(ocr_texts) if not combined_text: logger.warning("OCR文本内容太少,使用后备方案") return self._generate_fallback_summary(app_name, window_title) try: start_str = start_time.strftime("%Y-%m-%d %H:%M:%S") if start_time else "未知" end_str = end_time.strftime("%Y-%m-%d %H:%M:%S") if end_time else "进行中" system_prompt = get_prompt("event_summary", "system_assistant") user_prompt = get_prompt( "event_summary", "user_prompt", app_name=app_name or "未知应用", window_title=window_title or "未知窗口", start_time=start_str, end_time=end_str, ocr_text=combined_text, ) client = self.llm_client._get_client() response = client.chat.completions.create( model=self.llm_client.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0.3, max_tokens=200, ) if hasattr(response, "usage") and response.usage: log_token_usage( model=self.llm_client.model, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, endpoint="event_summary", response_type="summary_generation", feature_type="event_summary", ) content = (response.choices[0].message.content or "").strip() if content: extracted_content, original_content = self._extract_json_from_response(content) if extracted_content: result = self._parse_llm_response(extracted_content, original_content) if result: return result logger.warning("LLM响应解析失败,使用后备方案") else: logger.warning("LLM返回空内容,使用后备方案") except Exception as e: logger.error(f"LLM生成摘要失败: {e}", exc_info=True) return self._generate_fallback_summary(app_name, window_title) def _generate_fallback_summary( self, app_name: str | None, window_title: str | None ) -> dict[str, str]: """无OCR数据时的后备方案""" app_name = app_name or "未知应用" window_title = window_title or "未知窗口" app_display = app_name.replace(".exe", "").replace(".EXE", "") title = f"{app_display}使用" if len(title) > MAX_TITLE_LENGTH: title = title[:MAX_TITLE_LENGTH] summary = f"在**{app_display}**中活动" if window_title and window_title != "未知窗口": summary = f"使用**{app_display}**: {window_title[:50]}" return {"title": title, "summary": summary} # 全局实例 event_summary_service = EventSummaryService() def generate_event_summary_async(event_id: int): """异步生成事件摘要(在单独线程中调用) Args: event_id: 事件ID """ def _generate(): try: event_summary_service.generate_event_summary(event_id) except Exception as e: logger.error(f"异步生成事件摘要失败: {e}", exc_info=True) thread = threading.Thread(target=_generate, daemon=True) thread.start() ================================================ FILE: lifetrace/llm/journal_generation_service.py ================================================ """Journal generation service for objective and AI-view content.""" from __future__ import annotations from datetime import datetime from typing import Any from lifetrace.llm.llm_client import LLMClient from lifetrace.util.logging_config import get_logger from lifetrace.util.token_usage_logger import log_token_usage logger = get_logger() _MAX_ITEMS = 20 _RESPONSE_PREVIEW_LENGTH = 500 class JournalGenerationService: """Generate objective log and AI view for journals.""" def __init__(self) -> None: self.llm_client = LLMClient() def generate_objective( self, *, activities: list[dict[str, Any]], todos: list[dict[str, Any]], language: str, ) -> str: if not self.llm_client.is_available(): logger.warning("LLM client unavailable, using fallback objective log") return self._fallback_objective(activities, todos, language) try: system_prompt = ( "You are a calm journaling assistant. Summarize facts only, no judgment." ) user_prompt = self._build_objective_prompt(activities, todos, language) content = self._call_llm(system_prompt, user_prompt, response_type="objective") return content or self._fallback_objective(activities, todos, language) except Exception as exc: logger.error(f"Objective log generation failed: {exc}", exc_info=True) return self._fallback_objective(activities, todos, language) def generate_ai_view( self, *, title: str, content_original: str, activities: list[dict[str, Any]], todos: list[dict[str, Any]], language: str, ) -> str: if not self.llm_client.is_available(): logger.warning("LLM client unavailable, using fallback AI view") return self._fallback_ai_view(content_original, language) try: system_prompt = ( "You are a gentle observer. Describe the day in a supportive tone, no judgment." ) user_prompt = self._build_ai_prompt( title=title, content_original=content_original, activities=activities, todos=todos, language=language, ) content = self._call_llm(system_prompt, user_prompt, response_type="ai_view") return content or self._fallback_ai_view(content_original, language) except Exception as exc: logger.error(f"AI view generation failed: {exc}", exc_info=True) return self._fallback_ai_view(content_original, language) def _call_llm(self, system_prompt: str, user_prompt: str, response_type: str) -> str: client = self.llm_client._get_client() response = client.chat.completions.create( model=self.llm_client.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0.4, max_tokens=800, ) if hasattr(response, "usage") and response.usage: log_token_usage( model=self.llm_client.model, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, endpoint="journal_generation", response_type=response_type, feature_type="journal_generation", ) content = (response.choices[0].message.content or "").strip() if not content: logger.warning("LLM returned empty content for journal generation") return "" return content def _build_objective_prompt( self, activities: list[dict[str, Any]], todos: list[dict[str, Any]], language: str, ) -> str: activity_text = self._format_activity_text(activities) todo_text = self._format_todo_text(todos) return ( "Generate an objective log in the following language. " f"Language: {language}.\n\n" "Activities:\n" f"{activity_text}\n\n" "Todos:\n" f"{todo_text}\n\n" "Return a short timeline and a brief summary." ) def _build_ai_prompt( self, *, title: str, content_original: str, activities: list[dict[str, Any]], todos: list[dict[str, Any]], language: str, ) -> str: activity_text = self._format_activity_text(activities) todo_text = self._format_todo_text(todos) return ( "Write a gentle AI-view journal entry based on the original notes and the day data. " f"Language: {language}.\n\n" f"Title: {title or 'Untitled'}\n" f"Original Notes:\n{content_original}\n\n" "Activities:\n" f"{activity_text}\n\n" "Todos:\n" f"{todo_text}\n\n" "Keep it supportive, observational, and non-judgmental." ) def _format_activity_text(self, activities: list[dict[str, Any]]) -> str: if not activities: return "(none)" lines: list[str] = [] for activity in activities[:_MAX_ITEMS]: title = activity.get("title") or "Activity" summary = activity.get("summary") or "" start = self._format_time(activity.get("start_time")) line = f"- {start} {title}" if summary: line = f"{line}: {summary}" lines.append(line) if len(activities) > _MAX_ITEMS: lines.append(f"- ... ({len(activities) - _MAX_ITEMS} more)") return "\n".join(lines) def _format_todo_text(self, todos: list[dict[str, Any]]) -> str: if not todos: return "(none)" lines: list[str] = [] for todo in todos[:_MAX_ITEMS]: name = todo.get("name") or "Todo" status = todo.get("status") or "unknown" time_str = self._format_time(todo.get("deadline") or todo.get("start_time")) line = f"- {name} ({status})" if time_str: line = f"{line} @ {time_str}" lines.append(line) if len(todos) > _MAX_ITEMS: lines.append(f"- ... ({len(todos) - _MAX_ITEMS} more)") return "\n".join(lines) def _format_time(self, value: Any) -> str: if isinstance(value, datetime): return value.strftime("%H:%M") if value: return str(value) return "" def _fallback_objective( self, activities: list[dict[str, Any]], todos: list[dict[str, Any]], language: str, ) -> str: activity_count = len(activities) todo_count = len(todos) if language.lower().startswith("zh"): return ( "Objective log (ZH):\n" f"- Activities: {activity_count}\n" f"- Todos: {todo_count}\n" "- Detailed record unavailable" ) return ( "Objective log:\n" f"- Activities: {activity_count}\n" f"- Todos: {todo_count}\n" "- Detailed record unavailable" ) def _fallback_ai_view(self, content_original: str, language: str) -> str: preview = content_original.strip()[:_RESPONSE_PREVIEW_LENGTH] if language.lower().startswith("zh"): if preview: return f"AI view (ZH):\nYou noted: {preview}" return "AI view (ZH):\nNotes are light today, but your effort still counts." if preview: return f"AI view:\nYou noted: {preview}" return "AI view:\nToday is light on notes, but your effort still counts." journal_generation_service = JournalGenerationService() ================================================ FILE: lifetrace/llm/llm_client.py ================================================ """ LLM客户端模块 提供与OpenAI兼容API的交互 """ import contextlib from typing import TYPE_CHECKING, Any, cast from openai import OpenAI if TYPE_CHECKING: from openai.types.chat import ChatCompletionMessageParam else: ChatCompletionMessageParam = Any from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.token_usage_logger import setup_token_logger from .llm_client_intent import classify_intent_with_llm, rule_based_intent_classification from .llm_client_query import ( build_context_text, fallback_summary, generate_summary_with_llm, parse_query_with_llm, rule_based_parse, ) from .llm_client_vision import vision_chat logger = get_logger() class LLMClient: """LLM客户端,用于与OpenAI兼容的API进行交互(单例模式)""" _instance = None _initialized = False def __new__(cls): """实现单例模式""" if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self): """初始化LLM客户端""" if not LLMClient._initialized: self._initialize_client() setup_token_logger() LLMClient._initialized = True def _initialize_client(self): """内部方法:初始化或重新初始化客户端""" try: self.api_key = settings.llm.api_key self.base_url = settings.llm.base_url self.model = settings.llm.model invalid_values = [ "xxx", "YOUR_API_KEY_HERE", "YOUR_BASE_URL_HERE", "YOUR_LLM_KEY_HERE", ] if not self.api_key or self.api_key in invalid_values: logger.warning("LLM Key未配置或为默认占位符,LLM功能可能不可用") if not self.base_url or self.base_url in invalid_values: logger.warning("Base URL未配置或为默认占位符,LLM功能可能不可用") except Exception as e: logger.error(f"无法从配置文件读取LLM配置: {e}") self.api_key = "YOUR_LLM_KEY_HERE" self.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1" self.model = "qwen3-max" logger.warning("使用硬编码默认值初始化LLM客户端") try: if OpenAI is None: raise ImportError("openai 依赖未安装") self.client = OpenAI(base_url=self.base_url, api_key=self.api_key) logger.info(f"LLM客户端初始化成功,使用模型: {self.model}") logger.info(f"API Base URL: {self.base_url}") except Exception as e: logger.error(f"LLM客户端初始化失败: {e}") self.client = None def reinitialize(self): """重新初始化LLM客户端""" logger.info("正在重新初始化LLM客户端...") old_api_key = self.api_key if hasattr(self, "api_key") else None old_model = self.model if hasattr(self, "model") else None self._initialize_client() if old_api_key != self.api_key: logger.info( f"API Key已更新: {old_api_key[:10] if old_api_key else 'None'}... -> {self.api_key[:10]}..." ) if old_model != self.model: logger.info(f"模型已更新: {old_model} -> {self.model}") return self.is_available() def is_available(self) -> bool: """检查LLM客户端是否可用""" return self.client is not None def _get_client(self) -> OpenAI: if self.client is None: raise RuntimeError("LLM客户端不可用,无法进行请求") return self.client def classify_intent(self, user_query: str) -> dict[str, Any]: """分类用户意图""" if not self.is_available(): logger.warning("LLM客户端不可用,使用规则分类") return rule_based_intent_classification(user_query) return classify_intent_with_llm(self.client, self.model, user_query) def parse_query(self, user_query: str) -> dict[str, Any]: """解析用户查询""" if not self.is_available(): logger.warning("LLM客户端不可用,使用规则解析") return rule_based_parse(user_query) return parse_query_with_llm(self.client, self.model, user_query) def generate_summary(self, query: str, context_data: list[dict[str, Any]]) -> str: """生成摘要""" if not self.is_available(): logger.warning("LLM客户端不可用,使用规则总结") return fallback_summary(query, context_data) return generate_summary_with_llm(self.client, self.model, query, context_data) def chat( self, messages: list[dict[str, str]], temperature: float = 0.7, model: str | None = None, max_tokens: int | None = None, ) -> str: """通用非流式聊天方法,返回完整文本结果。""" if not self.is_available(): raise RuntimeError("LLM客户端不可用,无法进行文本聊天") try: client = self._get_client() response = client.chat.completions.create( model=model or self.model, messages=cast("list[ChatCompletionMessageParam]", messages), temperature=temperature, max_tokens=max_tokens, ) content = response.choices[0].message.content or "" return content except Exception as e: logger.error(f"文本聊天失败: {e}") raise def stream_chat( self, messages: list[dict[str, str]], temperature: float = 0.7, model: str | None = None, ): """通用流式聊天方法""" if not self.is_available(): raise RuntimeError("LLM客户端不可用,无法进行流式生成") try: # 关闭 enable_thinking 以提升性能(方案 B) # 如果未来需要思考模式,可以通过参数控制 client = self._get_client() stream = client.chat.completions.create( model=model or self.model, messages=cast("list[ChatCompletionMessageParam]", messages), temperature=temperature, # extra_body={"enable_thinking": True}, # 已移除以提升性能 stream=True, ) for chunk in stream: with contextlib.suppress(Exception): delta = chunk.choices[0].delta text = getattr(delta, "content", None) if text: yield text except Exception as e: logger.error(f"流式聊天失败: {e}") raise def vision_chat( self, screenshot_ids: list[int], prompt: str, model: str | None = None, temperature: float | None = None, max_tokens: int | None = None, ) -> dict[str, Any]: """视觉多模态聊天""" if not self.is_available(): raise RuntimeError("LLM客户端不可用,无法进行视觉多模态分析") return vision_chat( self.client, self.model, screenshot_ids, prompt, model, temperature, max_tokens, ) # 保持向后兼容的方法 def _rule_based_intent_classification(self, user_query: str) -> dict[str, Any]: """基于规则的意图分类(向后兼容)""" return rule_based_intent_classification(user_query) def _rule_based_parse(self, user_query: str) -> dict[str, Any]: """基于规则的查询解析(向后兼容)""" return rule_based_parse(user_query) def _build_context_text(self, context_data: list[dict[str, Any]]) -> str: """构建上下文文本(向后兼容)""" return build_context_text(context_data) def _fallback_summary(self, query: str, context_data: list[dict[str, Any]]) -> str: """备用总结(向后兼容)""" return fallback_summary(query, context_data) ================================================ FILE: lifetrace/llm/llm_client_intent.py ================================================ """ LLM 意图分类模块 包含意图分类和规则匹配逻辑 """ import json from typing import Any from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.token_usage_logger import log_token_usage logger = get_logger() def classify_intent_with_llm(client, model: str, user_query: str) -> dict[str, Any]: """使用LLM分类用户意图 Args: client: OpenAI客户端 model: 模型名称 user_query: 用户查询 Returns: 包含意图分类结果的字典 """ try: prompt = """ 请分析以下用户输入,判断用户的意图类型。 用户输入:"" 请判断这个输入属于以下哪种类型: 1. "database_query" - 需要查询数据库的请求(如:搜索截图、统计使用情况、查找特定应用等) 2. "general_chat" - 一般对话(如:问候、闲聊、询问功能等) 3. "system_help" - 系统帮助请求(如:如何使用、功能说明等) 请以JSON格式返回结果: { "intent_type": "database_query/general_chat/system_help", "needs_database": true/false } 只返回JSON,不要返回其他任何信息,不要使用markdown代码块标记。 """ user_content = prompt.replace("", user_query) response = client.chat.completions.create( model=model, messages=[ { "role": "system", "content": get_prompt("llm_client", "intent_classification"), }, {"role": "user", "content": user_content}, ], temperature=0.1, max_tokens=200, ) if hasattr(response, "usage") and response.usage: log_token_usage( model=model, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, endpoint="classify_intent", user_query=user_query, response_type="intent_classification", feature_type="event_assistant", ) result_text = response.choices[0].message.content.strip() logger.info("=== LLM意图分类响应 ===") logger.info(f"用户输入: {user_query}") logger.info(f"LLM回复: {result_text}") logger.info("=== 响应结束 ===") logger.info(f"LLM意图分类 - 用户输入: {user_query}") logger.info(f"LLM意图分类 - 原始响应: {result_text}") try: clean_text = result_text.strip() if clean_text.startswith("```json"): clean_text = clean_text[7:] if clean_text.endswith("```"): clean_text = clean_text[:-3] clean_text = clean_text.strip() result = json.loads(clean_text) logger.info( f"意图分类结果: {result['intent_type']}, 需要数据库: {result['needs_database']}" ) return result except json.JSONDecodeError: logger.warning(f"LLM返回的不是有效JSON: {result_text}") return rule_based_intent_classification(user_query) except Exception as e: logger.error(f"LLM意图分类失败: {e}") return rule_based_intent_classification(user_query) def rule_based_intent_classification(user_query: str) -> dict[str, Any]: """基于规则的意图分类(备用方案)""" query_lower = user_query.lower() # 数据库查询关键词 database_keywords = [ "搜索", "查找", "统计", "显示", "截图", "应用", "使用情况", "时间", "最近", "今天", "昨天", "本周", "上周", "本月", "上月", "search", "find", "show", "statistics", "screenshot", "app", "usage", ] # 一般对话关键词 chat_keywords = [ "你好", "谢谢", "再见", "怎么样", "如何", "为什么", "什么是", "hello", "hi", "thanks", "bye", "how", "what", "why", ] # 系统帮助关键词 help_keywords = [ "帮助", "功能", "使用方法", "教程", "说明", "介绍", "help", "function", "tutorial", "guide", "instruction", ] database_score = sum(1 for keyword in database_keywords if keyword in query_lower) chat_score = sum(1 for keyword in chat_keywords if keyword in query_lower) help_score = sum(1 for keyword in help_keywords if keyword in query_lower) if database_score > 0: intent_type = "database_query" needs_database = True elif help_score > 0: intent_type = "system_help" needs_database = False elif chat_score > 0: intent_type = "general_chat" needs_database = False else: intent_type = "database_query" needs_database = True return {"intent_type": intent_type, "needs_database": needs_database} ================================================ FILE: lifetrace/llm/llm_client_query.py ================================================ """ LLM 查询解析和摘要生成模块 """ import contextlib import json from datetime import datetime from typing import Any from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.time_utils import get_utc_now from lifetrace.util.token_usage_logger import log_token_usage logger = get_logger() def parse_query_with_llm(client, model: str, user_query: str) -> dict[str, Any]: """使用LLM解析用户查询 Args: client: OpenAI客户端 model: 模型名称 user_query: 用户查询 Returns: 解析后的查询条件字典 """ current_time = get_utc_now().astimezone() current_date_str = current_time.strftime("%Y-%m-%d %H:%M:%S") system_prompt = get_prompt("llm_client", "query_parsing") try: user_message = f"当前时间是:{current_date_str}\n请解析这个查询:{user_query}" response = client.chat.completions.create( messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}, ], model=model, temperature=0.1, ) if hasattr(response, "usage") and response.usage: log_token_usage( model=model, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, endpoint="parse_query", user_query=user_query, response_type="query_parsing", feature_type="event_assistant", ) result_text = response.choices[0].message.content.strip() logger.info("=== LLM查询解析响应 ===") logger.info(f"用户查询: {user_query}") logger.info(f"LLM回复: {result_text}") logger.info("=== 响应结束 ===") logger.info(f"LLM查询解析 - 用户查询: {user_query}") logger.info(f"LLM查询解析 - 原始响应: {result_text}") try: clean_text = result_text.strip() if clean_text.startswith("```json"): clean_text = clean_text[7:] if clean_text.endswith("```"): clean_text = clean_text[:-3] clean_text = clean_text.strip() result = json.loads(clean_text) return result except json.JSONDecodeError: logger.warning(f"LLM返回的不是有效JSON: {result_text}") return rule_based_parse(user_query) except Exception as e: logger.error(f"LLM解析失败: {e}") return rule_based_parse(user_query) def rule_based_parse(user_query: str) -> dict[str, Any]: """基于规则的查询解析(备用方案)""" query_lower = user_query.lower() # noqa: F841 keywords = [] time_keywords = ["今天", "昨天", "本周", "上周", "本月", "上月", "最近"] app_keywords = ["微信", "qq", "浏览器", "chrome", "edge", "word", "excel"] search_indicators = ["搜索", "查找", "包含", "关于", "找到"] has_search_intent = any(indicator in user_query for indicator in search_indicators) if has_search_intent: function_words = ["聊天", "浏览", "编辑", "查看", "打开", "使用", "运行"] blocked_words = { "搜索", "查找", "包含", "关于", "找到", "今天", "昨天", "的", "在", "上", "中", "里", } words = user_query.split() for word in words: if ( len(word) > 1 and word not in function_words and word not in time_keywords and word not in app_keywords and word not in blocked_words ): keywords.append(word) start_date = None end_date = None if "今天" in user_query: now = get_utc_now().astimezone() start_date = now.strftime("%Y-%m-%d 00:00:00") end_date = now.strftime("%Y-%m-%d 23:59:59") apps = [] for app in app_keywords: if app in user_query: apps.append(app) if any(kw in user_query for kw in ["统计", "数量", "时长"]): query_type = "statistics" elif any(kw in user_query for kw in ["搜索", "查找", "包含"]): query_type = "search" else: query_type = "summary" return { "start_date": start_date, "end_date": end_date, "app_names": apps or None, "keywords": keywords or None, "query_type": query_type, } def build_context_text(context_data: list[dict[str, Any]]) -> str: """构建上下文文本用于摘要生成""" max_ocr_text_length = 200 max_displayed_records = 10 if not context_data: return "没有找到相关的历史记录数据。" context_parts = [f"找到 {len(context_data)} 条相关记录:"] for i, record in enumerate(context_data[:max_displayed_records]): timestamp = record.get("timestamp", "未知时间") if timestamp and timestamp != "未知时间": with contextlib.suppress(ValueError, TypeError): dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) timestamp = dt.strftime("%Y-%m-%d %H:%M") app_name = record.get("app_name", "未知应用") ocr_text = record.get("ocr_text", "无文本内容") window_title = record.get("window_title", "") screenshot_id = record.get("screenshot_id") or record.get("id") if len(ocr_text) > max_ocr_text_length: ocr_text = ocr_text[:max_ocr_text_length] + "..." record_text = f"{i + 1}. [{app_name}] {timestamp}" if window_title: record_text += f" - {window_title}" if screenshot_id: record_text += f" [截图ID: {screenshot_id}]" record_text += f"\n 内容: {ocr_text}" context_parts.append(record_text) if len(context_data) > max_displayed_records: context_parts.append(f"... 还有 {len(context_data) - max_displayed_records} 条记录") return "\n\n".join(context_parts) def generate_summary_with_llm( client, model: str, query: str, context_data: list[dict[str, Any]] ) -> str: """使用LLM生成摘要 Args: client: OpenAI客户端 model: 模型名称 query: 用户查询 context_data: 上下文数据 Returns: 生成的摘要文本 """ system_prompt = get_prompt("llm_client", "summary_generation") context_text = build_context_text(context_data) user_prompt = f""" 用户查询:{query} 相关历史数据: {context_text} 请基于以上数据回答用户的查询。 """ try: response = client.chat.completions.create( messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], model=model, temperature=0.3, extra_body={"enable_thinking": True}, ) if hasattr(response, "usage") and response.usage: log_token_usage( model=model, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, endpoint="generate_summary", user_query=query, response_type="summary_generation", feature_type="event_assistant", additional_info={"context_records": len(context_data)}, ) result = response.choices[0].message.content.strip() logger.info("=== LLM总结生成响应 ===") logger.info(f"用户查询: {query}") logger.info(f"LLM回复: {result}") logger.info("=== 响应结束 ===") logger.info(f"LLM总结生成 - 用户查询: {query}") logger.info(f"LLM总结生成 - 生成结果: {result}") logger.info(f"LLM生成总结成功,长度: {len(result)}") return result except Exception as e: logger.error(f"LLM总结生成失败: {e}") return fallback_summary(query, context_data) def fallback_summary(query: str, context_data: list[dict[str, Any]]) -> str: """在LLM不可用或失败时的总结备选方案""" total_records = len(context_data) summary_parts = [ f"以下是根据历史数据的简要总结(查询: {query}):", f"- 共检索到相关记录 {total_records} 条", "- 涉及多个应用和时间点", "- 建议进一步细化查询条件以获得更精确的结果", ] return "\n".join(summary_parts) def build_context(context_data: list[dict[str, Any]]) -> str: """构建用于LLM生成的上下文文本""" context_parts = [] for i, item in enumerate(context_data[:50], start=1): text = item.get("text", "") if not text: text = ( item.get("ocr_result", {}).get("text", "") if isinstance(item.get("ocr_result"), dict) else "" ) app_name = ( item.get("metadata", {}).get("app_name", "") if isinstance(item.get("metadata"), dict) else "" ) timestamp = ( item.get("metadata", {}).get("created_at", "") if isinstance(item.get("metadata"), dict) else "" ) context_parts.append(f"[{i}] 应用: {app_name}, 时间: {timestamp}\n{text}\n") return "\n".join(context_parts) ================================================ FILE: lifetrace/llm/llm_client_vision.py ================================================ """ LLM 视觉多模态模块 包含视觉分析相关功能 """ from typing import Any from lifetrace.util.image_utils import get_screenshots_base64 from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.token_usage_logger import log_token_usage logger = get_logger() def get_vision_model(model: str | None, default_model: str) -> str: """获取视觉模型名称""" return model or settings.llm.vision_model or default_model def get_vision_temperature(temperature: float | None) -> float: """获取视觉模型温度参数""" return temperature if temperature is not None else settings.llm.temperature def get_vision_max_tokens(max_tokens: int | None) -> int: """获取视觉模型最大token数""" return max_tokens if max_tokens is not None else settings.llm.max_tokens def vision_chat( client, default_model: str, screenshot_ids: list[int], prompt: str, model: str | None = None, temperature: float | None = None, max_tokens: int | None = None, ) -> dict[str, Any]: """视觉多模态聊天:使用通义千问视觉模型分析多张图片 Args: client: OpenAI客户端 default_model: 默认模型名称 screenshot_ids: 截图ID列表 prompt: 文本提示词 model: 视觉模型名称 temperature: 温度参数 max_tokens: 最大生成token数 Returns: 包含响应和元信息的字典 """ try: screenshot_data = get_screenshots_base64(screenshot_ids) valid_screenshots = [item for item in screenshot_data if "base64_data" in item] if not valid_screenshots: raise ValueError("没有可用的截图,请检查截图ID是否正确") content = [] for item in valid_screenshots: content.append( { "type": "image_url", "image_url": {"url": item["base64_data"]}, } ) content.append({"type": "text", "text": prompt}) messages = [{"role": "user", "content": content}] vision_model = get_vision_model(model, default_model) vision_temperature = get_vision_temperature(temperature) vision_max_tokens = get_vision_max_tokens(max_tokens) logger.info(f"调用视觉模型 {vision_model},处理 {len(valid_screenshots)} 张截图") timeout_seconds = min(300, max(60, len(valid_screenshots) * 30)) response = client.chat.completions.create( model=vision_model, messages=messages, temperature=vision_temperature, max_tokens=vision_max_tokens, timeout=timeout_seconds, ) result_text = response.choices[0].message.content.strip() usage_info = None if hasattr(response, "usage") and response.usage: usage_info = { "prompt_tokens": response.usage.prompt_tokens, "completion_tokens": response.usage.completion_tokens, "total_tokens": response.usage.total_tokens, } log_token_usage( model=vision_model, input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, endpoint="vision_chat", user_query=prompt, response_type="vision_analysis", feature_type="vision_assistant", additional_info={ "screenshot_count": len(valid_screenshots), "screenshot_ids": screenshot_ids, }, ) logger.info(f"视觉模型分析完成,响应长度: {len(result_text)}") return { "response": result_text, "usage_info": usage_info, "model": vision_model, "screenshot_count": len(valid_screenshots), } except Exception as e: logger.error(f"视觉多模态分析失败: {e}", exc_info=True) raise ================================================ FILE: lifetrace/llm/ocr_todo_extractor.py ================================================ """OCR-based todo extraction helper module. This module handles todo extraction from OCR text content, including caching, rate limiting, and deduplication logic. """ import hashlib import json import re import time from datetime import datetime from typing import Any from lifetrace.llm.llm_client import LLMClient from lifetrace.storage import ocr_mgr, todo_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.time_parser import calculate_scheduled_time from lifetrace.util.time_utils import get_utc_now logger = get_logger() def _compute_text_hash(text_content: str) -> str | None: """对 OCR 文本进行标准化并计算哈希,用于判断是否重复。 必须与 OCRManager 中的逻辑保持一致。 """ normalized = " ".join((text_content or "").strip().split()) if not normalized: return None return hashlib.md5(normalized.encode("utf-8"), usedforsecurity=False).hexdigest() class OCRTodoExtractor: """OCR-based todo extraction helper class.""" def __init__(self, llm_client: LLMClient): """Initialize the extractor with an LLM client.""" self.llm_client = llm_client # 基于 OCR 文本的 LLM 调用缓存与频率控制 # key: text_hash, value: {"timestamp": float, "todos_raw": list[dict[str, Any]]} self._ocr_text_cache: dict[str, dict[str, Any]] = {} # 同一 text_hash 的最小 LLM 调用间隔(秒),用于限流 self._ocr_text_min_interval_sec: float = 60.0 # 纯内存缓存的有效期(秒),过期后即便有缓存仍会重新调用 LLM self._ocr_text_cache_ttl_sec: float = 3600.0 # 记录每个 text_hash 上一次真实调用 LLM 的时间戳 self._ocr_text_last_llm_call: dict[str, float] = {} def extract_todos( # noqa: PLR0911, PLR0912, PLR0915, C901 self, ocr_result_id: int, text_content: str, app_name: str, window_title: str, ) -> dict[str, Any]: """基于主动 OCR 的纯文本进行待办提取。 - 如果相同文本已经处理过,则跳过 LLM 调用。 - 始终在提示词中包含当前活跃 Todo 列表,但不对 LLM 输出做额外去重。 """ try: if not self.llm_client.is_available(): logger.warning("LLM客户端不可用,跳过基于OCR文本的待办提取") return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": True, "reason": "llm_unavailable", } text_hash = _compute_text_hash(text_content) if not text_hash: logger.info("OCR 文本为空或无有效内容,跳过待办提取") return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": True, "reason": "empty_text", } # 如果相同 text_hash 已存在于其他 OCR 结果中,则认为已处理过,跳过 LLM 调用 existing = ocr_mgr.get_by_text_hash(text_hash) if existing and existing.get("id") != ocr_result_id: logger.info( "检测到已处理过相同 OCR 文本,跳过本次待办提取:" f"current_id={ocr_result_id}, existing_id={existing.get('id')}" ) return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": True, "reason": "text_already_processed", } # 获取当前活跃 Todo 列表,用于提示词 existing_todos = todo_mgr.get_active_todos_for_prompt(limit=100) existing_todos_json = json.dumps(existing_todos, ensure_ascii=False) system_prompt = get_prompt("auto_todo_detection", "system_assistant") user_prompt = get_prompt( "auto_todo_detection", "user_prompt", existing_todos_json=existing_todos_json, ) # 将 OCR 文本附加在用户提示词后面,并在提示中强调不要重复已有待办 user_content = ( f"{user_prompt}\n\n" "重要规则:\n" "1. 如果候选待办在当前已有待办列表中已经存在(尤其是标题和时间信息相同或非常相似)," "请不要重复输出这些待办,仅输出真正新的待办。\n" "2. 可以适当润色标题,但不要把同一条待办拆分成多条含义相同的待办。\n\n" f"当前应用:{app_name}\n" f"窗口标题:{window_title}\n" f"OCR 文本内容如下,请仅基于这些文本提取新的待办事项:\n{text_content}" ) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}, ] # 频率控制与缓存:尽量减少对同一 text_hash 的重复 LLM 调用 now_ts = time.time() cached_entry = self._ocr_text_cache.get(text_hash) todos: list[dict[str, Any]] # 如果有有效缓存且未过期,直接复用缓存结果,避免再次调用 LLM if ( cached_entry and now_ts - cached_entry.get("timestamp", 0.0) <= self._ocr_text_cache_ttl_sec ): logger.info( "基于 OCR 文本待办提取命中缓存,跳过 LLM 调用 " f"(ocr_result_id={ocr_result_id}, text_hash={text_hash})" ) todos = cached_entry.get("todos_raw") or [] else: # 如果距离上次真实 LLM 调用的时间间隔过短,则进行限流 last_call_ts = self._ocr_text_last_llm_call.get(text_hash) if ( last_call_ts is not None and now_ts - last_call_ts < self._ocr_text_min_interval_sec ): logger.info( "距离上次基于相同 OCR 文本的 LLM 调用时间过短,跳过本次调用 " f"(ocr_result_id={ocr_result_id}, text_hash={text_hash})" ) # 如果存在旧缓存则复用,否则直接跳过(返回空结果) if cached_entry and cached_entry.get("todos_raw"): todos = cached_entry.get("todos_raw") or [] else: return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": True, "reason": "too_frequent", "created_count": 0, "created_todos": [], } else: logger.info("开始基于 OCR 文本调用 LLM 进行待办提取") response_text = self.llm_client.chat( messages=messages, temperature=0.3, max_tokens=1500, ) # 记录本次真实 LLM 调用时间 self._ocr_text_last_llm_call[text_hash] = now_ts # 仅做 JSON 解析,并在本地进行去重(基于标题+时间),避免重复创建相同待办 try: json_match = re.search(r"\{.*\}", response_text, re.DOTALL) if not json_match: logger.warning("基于 OCR 文本的 LLM 响应中未找到 JSON,返回空结果") return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": False, "error_message": "no_json_in_response", "created_count": 0, "created_todos": [], } json_str = json_match.group(0) data = json.loads(json_str) todos = data.get("new_todos") or data.get("todos") or [] if not isinstance(todos, list): logger.warning("LLM 返回的 todos 字段不是列表,返回空结果") todos = [] # 将本次解析结果写入内存缓存 self._ocr_text_cache[text_hash] = { "timestamp": now_ts, "todos_raw": todos, } except Exception as e: logger.error( f"解析基于 OCR 文本的 LLM 响应失败: {e}\n原始响应: {response_text[:200]}" ) return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": False, "error_message": "parse_error", "created_count": 0, "created_todos": [], } # 从这里开始,todos 已经就绪(来自缓存或本次 LLM 调用结果) # 后续统一执行本地去重与 draft 待办创建逻辑 try: # 构建去重集合:使用数据库中现有的 active/draft 待办,按 (标题, 时间) 去重 dedupe_keys: set[tuple[str, str | None]] = set() try: existing_todos_full = todo_mgr.list_todos(limit=1000, offset=0, status=None) for t in existing_todos_full: name = (t.get("name") or "").strip() if not name: continue schedule_time = t.get("start_time") or t.get("deadline") time_key = ( schedule_time.isoformat() if isinstance(schedule_time, datetime) else None ) dedupe_keys.add((name, time_key)) except Exception as e: logger.warning(f"构建去重集合失败,将跳过本地去重逻辑: {e}") # 基于 LLM 返回的 todos 创建 draft 状态的待办 created_todos: list[dict[str, Any]] = [] created_count = 0 for todo_data in todos: try: title = (todo_data.get("title") or "").strip() if not title: logger.warning("跳过标题为空的待办(OCR 文本提取)") continue description = todo_data.get("description") if isinstance(description, str): description = description.strip() or None else: description = None time_info = todo_data.get("time_info") or {} scheduled_time = None if isinstance(time_info, dict) and time_info: try: scheduled_time = calculate_scheduled_time(time_info, get_utc_now()) except Exception as e: logger.warning(f"计算 OCR 文本待办 scheduled_time 失败: {e}") # 使用 (标题 + 时间) 进行本地去重,避免重复创建同一待办 try: time_key = ( scheduled_time.isoformat() if isinstance(scheduled_time, datetime) else None ) key = (title, time_key) if key in dedupe_keys: logger.info( "检测到已存在相同标题与时间的待办,跳过创建:" f"title={title!r}, scheduled_time={time_key!r}" ) continue # 将当前 key 加入去重集合,避免本批次内重复 dedupe_keys.add(key) except Exception as e: logger.warning(f"本地去重检查失败,仍然尝试创建待办: {e}") source_text = (todo_data.get("source_text") or "").strip() confidence = todo_data.get("confidence") # 构建 user_notes,记录来源信息 user_notes_parts = [ f"OCR 结果 ID: {ocr_result_id}", f"应用: {app_name}", ] if window_title: user_notes_parts.append(f"窗口: {window_title}") if source_text: user_notes_parts.append(f"来源文本: {source_text}") if isinstance(time_info, dict) and time_info.get("raw_text"): user_notes_parts.append(f"时间: {time_info.get('raw_text')}") if isinstance(confidence, int | float): user_notes_parts.append(f"置信度: {float(confidence):.2%}") user_notes = "\n".join(user_notes_parts) todo_id = todo_mgr.create_todo( name=title, description=description, user_notes=user_notes, start_time=scheduled_time, status="draft", priority="none", tags=["自动提取"], ) if todo_id: created_count += 1 created_todos.append( { "id": todo_id, "name": title, "scheduled_time": scheduled_time.isoformat() if scheduled_time else None, } ) logger.info( f"基于 OCR 文本创建 draft 待办: {todo_id} - {title} (ocr_result_id={ocr_result_id})" ) else: logger.warning( f"基于 OCR 文本创建待办失败(create_todo 返回 None): {title}" ) except Exception as e: logger.error( f"处理 OCR 文本待办数据失败: {e}, 数据: {todo_data}", exc_info=True, ) continue return { "ocr_result_id": ocr_result_id, "todos": todos, "skipped": False, "created_count": created_count, "created_todos": created_todos, } except Exception as e: logger.error(f"处理 OCR 文本待办创建逻辑失败: {e}", exc_info=True) return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": False, "error_message": "parse_error", "created_count": 0, "created_todos": [], } except Exception as e: logger.error(f"基于 OCR 文本的待办提取失败: {e}", exc_info=True) return { "ocr_result_id": ocr_result_id, "todos": [], "skipped": False, "error_message": str(e), } ================================================ FILE: lifetrace/llm/rag_fallback.py ================================================ """ RAG 回退响应模块 包含备用响应生成逻辑 """ import contextlib from datetime import datetime from typing import Any from lifetrace.util.logging_config import get_logger logger = get_logger() def summarize_retrieved_data(retrieved_data: list[dict[str, Any]]) -> dict[str, Any]: """总结检索到的数据""" if not retrieved_data: return {"apps": {}, "time_range": None, "total": 0} app_counts = {} timestamps = [] for record in retrieved_data: app_name = record.get("app_name", "未知应用") app_counts[app_name] = app_counts.get(app_name, 0) + 1 timestamp = record.get("timestamp") if timestamp: timestamps.append(timestamp) time_range = None if timestamps: timestamps.sort() time_range = {"earliest": timestamps[0], "latest": timestamps[-1]} return { "apps": app_counts, "time_range": time_range, "total": len(retrieved_data), } def fallback_response( user_query: str, retrieved_data: list[dict[str, Any]], stats: dict[str, Any] | None = None, ) -> str: """备用响应生成(当LLM不可用时)""" _ = stats if not retrieved_data: return f"抱歉,没有找到与查询 '{user_query}' 相关的历史记录。" response_parts = [f"根据您的查询 '{user_query}',我找到了以下信息:", ""] response_parts.append(f"📊 总共找到 {len(retrieved_data)} 条相关记录") app_summary = summarize_retrieved_data(retrieved_data) if app_summary["apps"]: response_parts.append("\n📱 应用分布:") for app, count in sorted(app_summary["apps"].items(), key=lambda x: x[1], reverse=True): response_parts.append(f" • {app}: {count} 条记录") if app_summary["time_range"]: with contextlib.suppress(ValueError, TypeError): earliest = datetime.fromisoformat( app_summary["time_range"]["earliest"].replace("Z", "+00:00") ) latest = datetime.fromisoformat( app_summary["time_range"]["latest"].replace("Z", "+00:00") ) response_parts.append( f"\n⏰ 时间范围: {earliest.strftime('%Y-%m-%d %H:%M')} 至 {latest.strftime('%Y-%m-%d %H:%M')}" ) if retrieved_data: response_parts.append("\n📝 最新记录示例:") latest_record = retrieved_data[0] timestamp = latest_record.get("timestamp", "未知时间") app_name = latest_record.get("app_name", "未知应用") ocr_text = latest_record.get("ocr_text", "无内容")[:100] response_parts.append(f" 时间: {timestamp}") response_parts.append(f" 应用: {app_name}") response_parts.append(f" 内容: {ocr_text}...") response_parts.append("\n💡 提示:您可以使用更具体的关键词来获得更精确的结果。") return "\n".join(response_parts) def generate_direct_response(llm_client, user_query: str, intent_result: dict[str, Any]) -> str: """为不需要数据库查询的用户输入生成直接回复""" try: intent_type = intent_result.get("intent_type", "general_chat") if intent_type == "system_help": system_prompt = """ 你是LifeTrace的智能助手。LifeTrace是一个生活轨迹记录和分析系统,主要功能包括: 1. 自动截图记录用户的屏幕活动 2. OCR文字识别和内容分析 3. 应用使用情况统计 4. 智能搜索和查询功能 请根据用户的问题提供有用的帮助信息。 """ else: system_prompt = """ 你是LifeTrace的智能助手,请以友好、自然的方式与用户对话。 如果用户需要查询数据或统计信息,请引导他们使用具体的查询语句。 """ response = llm_client.client.chat.completions.create( model=llm_client.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_query}, ], temperature=0.7, max_tokens=500, ) llm_response = response.choices[0].message.content.strip() logger.info(f"[LLM Direct Response] {llm_response}") logger.info(f"LLM直接响应: {llm_response}") return llm_response except Exception as e: logger.error(f"直接响应生成失败: {e}") return fallback_direct_response(user_query, intent_result) def fallback_direct_response(user_query: str, intent_result: dict[str, Any]) -> str: """当LLM不可用时的直接回复备用方案""" intent_type = intent_result.get("intent_type", "general_chat") if intent_type == "system_help": return """ LifeTrace是一个生活轨迹记录和分析系统,主要功能包括: 📸 **自动截图记录** - 定期捕获屏幕内容 - 记录应用使用情况 🔍 **智能搜索** - 搜索历史截图 - 基于OCR文字内容查找 📊 **使用统计** - 应用使用时长统计 - 活动模式分析 💬 **智能问答** - 自然语言查询 - 个性化数据分析 如需查询具体数据,请使用如"搜索包含编程的截图"或"统计最近一周的应用使用情况"等语句。 """ elif intent_type == "general_chat": greetings = [ "你好!我是LifeTrace的智能助手,很高兴为您服务!", "您好!有什么可以帮助您的吗?", "欢迎使用LifeTrace!我可以帮您查询和分析您的生活轨迹数据。", ] if any(word in user_query.lower() for word in ["你好", "hello", "hi"]): return greetings[0] + "\n\n您可以询问我关于LifeTrace的功能,或者直接查询您的数据。" elif any(word in user_query.lower() for word in ["谢谢", "thanks"]): return "不客气!如果还有其他问题,随时可以问我。" else: return greetings[1] + "\n\n您可以尝试搜索截图、查询应用使用情况,或者询问系统功能。" else: return "我理解您的问题,但可能需要更多信息才能提供准确的回答。您可以尝试更具体的查询,比如搜索特定内容或统计使用情况。" ================================================ FILE: lifetrace/llm/rag_service.py ================================================ """ RAG (检索增强生成) 服务 整合查询解析、数据检索、上下文构建和LLM生成 """ import asyncio import contextlib from collections.abc import Generator from datetime import datetime from typing import Any from lifetrace.llm.context_builder import ContextBuilder from lifetrace.llm.llm_client import LLMClient from lifetrace.llm.retrieval_service import RetrievalService from lifetrace.util.language import get_language_instruction from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.query_parser import QueryParser from lifetrace.util.time_utils import get_utc_now from .rag_fallback import ( fallback_direct_response, fallback_response, generate_direct_response, summarize_retrieved_data, ) from .rag_stream import ( RAGStreamContext, get_statistics_if_needed, stream_direct_response, stream_with_retrieval, ) logger = get_logger() class RAGService: """RAG (检索增强生成) 服务""" def __init__(self): """初始化RAG服务""" self.llm_client = LLMClient() self.retrieval_service = RetrievalService() self.context_builder = ContextBuilder() self.query_parser = QueryParser(self.llm_client) logger.info("RAG服务初始化完成") def _handle_direct_query( self, user_query: str, intent_result: dict, start_time: datetime ) -> dict[str, Any]: """处理不需要数据库查询的直接回复""" logger.info(f"用户意图不需要数据库查询: {intent_result['intent_type']}") if self.llm_client.is_available(): response_text = generate_direct_response(self.llm_client, user_query, intent_result) else: response_text = fallback_direct_response(user_query, intent_result) processing_time = (get_utc_now() - start_time).total_seconds() return { "success": True, "response": response_text, "query_info": { "original_query": user_query, "intent_classification": intent_result, "requires_database": False, }, "performance": { "processing_time_seconds": processing_time, "timestamp": start_time.isoformat(), }, } def _get_statistics_if_needed( self, query_type: str, user_query: str, parsed_query ) -> dict | None: """根据查询类型获取统计信息""" return get_statistics_if_needed( self.retrieval_service, query_type, user_query, parsed_query ) def _build_context_for_query( self, query_type: str, user_query: str, retrieved_data: list, stats: dict | None ) -> str: """根据查询类型构建上下文""" logger.info("开始构建上下文") if query_type == "statistics": return self.context_builder.build_statistics_context( user_query, retrieved_data, stats or {} ) if query_type == "search": return self.context_builder.build_search_context(user_query, retrieved_data) return self.context_builder.build_summary_context(user_query, retrieved_data) async def process_query(self, user_query: str, max_results: int = 50) -> dict[str, Any]: """处理用户查询的完整RAG流水线""" start_time = get_utc_now() try: logger.info(f"开始处理查询: {user_query}") intent_result = self.llm_client.classify_intent(user_query) if not intent_result.get("needs_database", True): return self._handle_direct_query(user_query, intent_result, start_time) logger.info("需要数据库查询,开始查询解析") parsed_query = self.query_parser.parse_query(user_query) query_type = "statistics" if "统计" in user_query else "search" logger.info("开始数据检索") retrieved_data = self.retrieval_service.search_by_conditions(parsed_query, max_results) stats = self._get_statistics_if_needed(query_type, user_query, parsed_query) context_text = self._build_context_for_query( query_type, user_query, retrieved_data, stats ) logger.info("开始LLM生成") if self.llm_client.is_available(): response_text = self.llm_client.generate_summary(user_query, retrieved_data) else: response_text = fallback_response(user_query, retrieved_data, stats) processing_time = (get_utc_now() - start_time).total_seconds() logger.info(f"查询处理完成,耗时 {processing_time:.2f} 秒") return { "success": True, "response": response_text, "query_info": { "original_query": user_query, "intent_classification": intent_result, "parsed_query": parsed_query, "query_type": query_type, "requires_database": True, }, "retrieval_info": { "total_found": len(retrieved_data), "data_summary": summarize_retrieved_data(retrieved_data), }, "context_info": { "context_length": len(context_text), "llm_available": self.llm_client.is_available(), }, "performance": { "processing_time_seconds": processing_time, "timestamp": start_time.isoformat(), }, "statistics": stats, } except Exception as e: logger.error(f"RAG查询处理失败: {e}") return { "success": False, "error": str(e), "response": "抱歉,处理您的查询时出现了错误。请稍后重试。", "query_info": {"original_query": user_query}, "performance": { "processing_time_seconds": (get_utc_now() - start_time).total_seconds(), "timestamp": start_time.isoformat(), }, } def process_query_sync(self, user_query: str, max_results: int = 50) -> dict[str, Any]: """同步版本的查询处理""" return asyncio.run(self.process_query(user_query, max_results)) def post_stream_decision(self, user_query: str, output_text: str) -> None: """流式输出完成后的判定/记录钩子""" try: if not output_text: return keywords = ["免责声明", "敏感内容", "注意", "总结"] if any(kw in output_text for kw in keywords): logger.info( f"[post_stream] 输出包含关键提示,query='{user_query[:50]}...' 触发标记" ) else: logger.debug("[post_stream] 无特殊标记") except Exception as e: logger.debug(f"[post_stream] 处理异常已忽略: {e}") def stream_query( self, user_query: str, max_results: int = 50, temperature_direct: float = 0.7, temperature_rag: float = 0.3, ) -> Generator[str]: """流式处理用户查询""" try: intent_result = self.llm_client.classify_intent(user_query) needs_db = intent_result.get("needs_database", True) if not needs_db: yield from stream_direct_response( self.llm_client, user_query, intent_result, temperature_direct, self.post_stream_decision, fallback_direct_response, ) return ctx = RAGStreamContext( llm_client=self.llm_client, retrieval_service=self.retrieval_service, context_builder=self.context_builder, query_parser=self.query_parser, post_stream_callback=self.post_stream_decision, fallback_response_func=fallback_response, get_statistics_func=self._get_statistics_if_needed, ) yield from stream_with_retrieval(ctx, user_query, max_results, temperature_rag) except Exception as e: logger.error(f"RAG 流式处理失败: {e}") error_text = "\n[提示] 流式处理出现异常,已结束。" yield error_text with contextlib.suppress(Exception): self.post_stream_decision(user_query, error_text) def get_query_suggestions(self, partial_query: str = "") -> list[str]: """获取查询建议""" suggestions = [ "总结今天的微信聊天记录", "查找包含'会议'的所有记录", "统计最近一周各应用的使用情况", "搜索昨天浏览器中的内容", "总结最近的工作相关截图", "查找包含'项目'关键词的记录", "统计本月QQ聊天记录数量", "搜索最近3天的学习资料", "总结上周的网页浏览记录", "查找包含'文档'的所有应用记录", ] if partial_query: filtered_suggestions = [ s for s in suggestions if any(word in s for word in partial_query.split()) ] return filtered_suggestions[:5] return suggestions[:5] def get_supported_query_types(self) -> dict[str, Any]: """获取支持的查询类型信息""" return { "query_types": { "summary": { "name": "总结", "description": "对历史记录进行总结和概括", "examples": ["总结今天的微信聊天", "概括最近的工作记录"], }, "search": { "name": "搜索", "description": "搜索包含特定关键词的记录", "examples": ["查找包含'会议'的记录", "搜索项目相关内容"], }, "statistics": { "name": "统计", "description": "统计和分析历史记录数据", "examples": ["统计各应用使用情况", "分析最近一周的活动"], }, }, "supported_apps": [ "WeChat", "QQ", "Browser", "Chrome", "Firefox", "Edge", "Word", "Excel", "PowerPoint", "Notepad", "VSCode", ], "time_expressions": [ "今天", "昨天", "最近3天", "本周", "上周", "本月", "上月", ], } def health_check(self) -> dict[str, Any]: """健康检查""" return { "rag_service": "healthy", "llm_client": ("available" if self.llm_client.is_available() else "unavailable"), "database": "connected", "components": { "retrieval_service": "ready", "context_builder": "ready", "query_parser": "ready", }, "timestamp": get_utc_now().isoformat(), } async def process_query_stream( self, user_query: str, session_id: str | None = None, lang: str = "zh", ) -> dict[str, Any]: """为流式接口处理查询,返回构建好的 messages 和 temperature""" try: logger.info(f"[stream] 开始处理查询: {user_query}, session_id: {session_id}") intent_result = self.llm_client.classify_intent(user_query) needs_db = intent_result.get("needs_database", True) # 构建消息 if needs_db: parsed_query = self.query_parser.parse_query(user_query) query_type = "statistics" if "统计" in user_query else "search" retrieved_data = self.retrieval_service.search_by_conditions(parsed_query, 500) # 构建上下文 if query_type == "statistics": stats = self.retrieval_service.get_statistics(parsed_query) context_text = self.context_builder.build_statistics_context( user_query, retrieved_data, stats ) else: context_text = self.context_builder.build_search_context( user_query, retrieved_data ) logger.debug(f"构建的上下文内容: {context_text}") # 注入语言指令 context_text += get_language_instruction(lang) messages = [{"role": "system", "content": context_text}] temperature = 0.3 else: # 不需要数据库查询的直接回复 intent_type = intent_result.get("intent_type", "general_chat") if intent_type == "system_help": system_prompt = get_prompt("rag", "system_help") else: system_prompt = get_prompt("rag", "general_chat") # 注入语言指令 system_prompt += get_language_instruction(lang) messages = [{"role": "system", "content": system_prompt}] temperature = 0.7 # 添加当前用户消息 messages.append({"role": "user", "content": user_query}) return { "success": True, "messages": messages, "temperature": temperature, "intent_result": intent_result, } except Exception as e: logger.error(f"[stream] 处理查询失败: {e}") return { "success": False, "response": f"处理查询时出现错误: {e!s}", "messages": [], "temperature": 0.7, } ================================================ FILE: lifetrace/llm/rag_stream.py ================================================ """ RAG 流式处理模块 包含流式查询处理逻辑 """ from collections.abc import Callable, Generator from dataclasses import dataclass from typing import Any from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.query_parser import QueryConditions logger = get_logger() @dataclass class RAGStreamContext: """RAG 流式处理上下文,封装所有服务依赖""" llm_client: Any retrieval_service: Any context_builder: Any query_parser: Any post_stream_callback: Callable[[str, str], None] fallback_response_func: Callable[..., str] get_statistics_func: Callable[..., dict | None] def stream_direct_response( llm_client, user_query: str, intent_result: dict, temperature: float, post_stream_callback: Callable[[str, str], None], fallback_response_func: Callable[..., str], ) -> Generator[str]: """流式处理直接对话(不需要数据库)""" if not llm_client.is_available(): fallback_text = fallback_response_func(user_query, intent_result) yield fallback_text post_stream_callback(user_query, fallback_text) return intent_type = intent_result.get("intent_type", "general_chat") system_prompt = get_prompt( "rag", "system_help" if intent_type == "system_help" else "general_chat" ) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_query}, ] output_chunks: list[str] = [] for text in llm_client.stream_chat(messages=messages, temperature=temperature): if text: output_chunks.append(text) yield text post_stream_callback(user_query, "".join(output_chunks)) def stream_with_retrieval( ctx: RAGStreamContext, user_query: str, max_results: int, temperature: float, ) -> Generator[str]: """流式处理带检索的查询 Args: ctx: RAG 流式处理上下文 user_query: 用户查询 max_results: 最大结果数 temperature: 温度参数 """ parsed_query = ctx.query_parser.parse_query(user_query) query_type = "statistics" if "统计" in user_query else "search" retrieved_data = ctx.retrieval_service.search_by_conditions(parsed_query, max_results) # 获取统计信息 stats = None if query_type == "statistics" or "统计" in user_query: try: stats = ctx.get_statistics_func(query_type, user_query, parsed_query) except Exception: stats = None # 构建上下文 context_text = _build_context_for_query( ctx.context_builder, query_type, user_query, retrieved_data, stats ) # LLM 不可用时返回备选 if not ctx.llm_client.is_available(): fallback_text = ctx.fallback_response_func(user_query, retrieved_data, stats) yield fallback_text ctx.post_stream_callback(user_query, fallback_text) return # 流式生成 system_prompt = get_prompt("rag", "history_analysis") user_prompt = get_prompt("rag", "user_query_template", query=user_query, context=context_text) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] output_chunks: list[str] = [] for text in ctx.llm_client.stream_chat(messages=messages, temperature=temperature): if text: output_chunks.append(text) yield text ctx.post_stream_callback(user_query, "".join(output_chunks)) def _build_context_for_query( context_builder, query_type: str, user_query: str, retrieved_data: list, stats: dict | None ) -> str: """根据查询类型构建上下文""" logger.info("开始构建上下文") if query_type == "statistics": return context_builder.build_statistics_context(user_query, retrieved_data, stats) if query_type == "search": return context_builder.build_search_context(user_query, retrieved_data) return context_builder.build_summary_context(user_query, retrieved_data) def get_statistics_if_needed( retrieval_service, query_type: str, user_query: str, parsed_query ) -> dict | None: """根据查询类型获取统计信息""" if query_type != "statistics" and "统计" not in user_query: return None if isinstance(parsed_query, QueryConditions): conditions = parsed_query else: conditions = QueryConditions( start_date=parsed_query.get("start_date"), end_date=parsed_query.get("end_date"), app_names=parsed_query.get("app_names", []), keywords=parsed_query.get("keywords", []), ) return retrieval_service.get_statistics(conditions) ================================================ FILE: lifetrace/llm/retrieval_service.py ================================================ from datetime import timedelta from typing import Any from sqlalchemy import func, or_ from lifetrace.storage import get_session from lifetrace.storage.models import OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.query_parser import QueryConditions, QueryParser from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 常量定义 MAX_LOG_PREVIEW_RECORDS = 3 # 日志预览最大记录数 MAX_APP_DISTRIBUTION_DISPLAY = 5 # 应用分布显示最大数量 TIME_RECENCY_DAY_THRESHOLD = 1 # 时间新近性阈值(天) TIME_RECENCY_WEEK_THRESHOLD = 7 # 时间新近性阈值(周) class RetrievalService: """检索服务,用于从数据库中检索相关的截图和OCR数据""" def __init__(self): """ 初始化检索服务 """ self.query_parser = QueryParser() logger.info("检索服务初始化完成") def _build_base_query(self, session: Any, conditions: QueryConditions) -> Any: """构建基础查询""" query = session.query(Screenshot).join( OCRResult, col(Screenshot.id) == col(OCRResult.screenshot_id) ) # 添加时间范围过滤 if conditions.start_date: query = query.filter(col(Screenshot.created_at) >= conditions.start_date) if conditions.end_date: query = query.filter(col(Screenshot.created_at) <= conditions.end_date) # 添加应用名称过滤 if conditions.app_names: app_filters = [ col(Screenshot.app_name).ilike(f"%{app}%") for app in conditions.app_names ] query = query.filter(or_(*app_filters)) # 添加关键词过滤 if conditions.keywords: keyword_filters = [ col(OCRResult.text_content).ilike(f"%{keyword}%") for keyword in conditions.keywords ] query = query.filter(or_(*keyword_filters)) return query.order_by(col(Screenshot.created_at).desc()) def _convert_screenshot_to_dict( self, session: Any, screenshot: Screenshot, conditions: QueryConditions ) -> dict[str, Any]: """将截图转换为字典格式""" ocr_results = ( session.query(OCRResult).filter(col(OCRResult.screenshot_id) == screenshot.id).all() ) ocr_text = " ".join([ocr.text_content for ocr in ocr_results if ocr.text_content]) return { "screenshot_id": screenshot.id, "timestamp": screenshot.created_at.isoformat() if screenshot.created_at else None, "app_name": screenshot.app_name, "window_title": screenshot.window_title, "file_path": screenshot.file_path, "ocr_text": ocr_text, "ocr_count": len(ocr_results), "relevance_score": self._calculate_relevance(screenshot, ocr_text, conditions), } def _log_query_results(self, data_list: list[dict[str, Any]]) -> None: """记录查询结果日志""" logger.info("=" * 60) logger.info(f"📊 查询结果: 找到 {len(data_list)} 条记录") logger.info("=" * 60) if not data_list: return logger.info("📝 OCR内容详情 (前3条):") for i, item in enumerate(data_list[:MAX_LOG_PREVIEW_RECORDS]): ocr_text = item.get("ocr_text", "") logger.info(f" [{i + 1}] 截图ID: {item['screenshot_id']}") logger.info(f" 应用: {item['app_name']}") logger.info(f" 时间: {item['timestamp']}") logger.info(f" OCR文本长度: {len(ocr_text)} 字符") logger.info(f" OCR文本预览: {ocr_text[:100] if ocr_text else '❌ 无OCR内容'}") if not ocr_text: logger.warning(" ⚠️ 警告: 这条记录没有OCR文本!") # 统计有无OCR内容的记录 has_ocr = sum(1 for item in data_list if item.get("ocr_text")) no_ocr = len(data_list) - has_ocr logger.info("📈 OCR统计:") logger.info(f" ✅ 有OCR内容: {has_ocr} 条") logger.info(f" ❌ 无OCR内容: {no_ocr} 条") logger.info("=" * 60) logger.info("=== 查询完成 ===") logger.info("=" * 60) def search_by_conditions( self, conditions: QueryConditions, limit: int = 50 ) -> list[dict[str, Any]]: """ 根据查询条件检索数据 Args: conditions: 查询条件 limit: 返回结果的最大数量 Returns: 检索到的数据列表 """ try: logger.info(f"执行数据库查询 - 条件: {conditions}, 限制: {limit}") with get_session() as session: query = self._build_base_query(session, conditions) # 限制结果数量 - 优先使用QueryConditions中的limit effective_limit = conditions.limit if conditions.limit else limit results = query.limit(effective_limit).all() # 转换为字典格式 data_list = [ self._convert_screenshot_to_dict(session, screenshot, conditions) for screenshot in results ] # 按时间排序 data_list.sort(key=lambda x: x["timestamp"], reverse=True) # 记录查询结果 self._log_query_results(data_list) logger.info(f"检索完成,找到 {len(data_list)} 条记录") return data_list except Exception as e: logger.error(f"数据检索失败: {e}") return [] def search_by_query(self, user_query: str, limit: int = 50) -> list[dict[str, Any]]: """ 根据用户查询检索数据 Args: user_query: 用户的自然语言查询 limit: 返回结果的最大数量 Returns: 检索到的数据列表 """ # 解析查询 conditions = self.query_parser.parse_query(user_query) logger.info(f"查询解析结果: {conditions}") # 执行检索 return self.search_by_conditions(conditions, limit) def search_recent( self, hours: int = 24, app_name: str | None = None, limit: int = 20 ) -> list[dict[str, Any]]: """ 检索最近的记录 Args: hours: 最近多少小时的记录 app_name: 可选的应用名称过滤 limit: 返回结果的最大数量 Returns: 检索到的数据列表 """ end_time = get_utc_now() start_time = end_time - timedelta(hours=hours) conditions = QueryConditions( start_date=start_time, end_date=end_time, app_names=[app_name] if app_name else None, ) return self.search_by_conditions(conditions, limit) def search_by_app(self, app_name: str, days: int = 7, limit: int = 50) -> list[dict[str, Any]]: """ 按应用名称检索记录 Args: app_name: 应用名称 days: 检索最近多少天的记录 limit: 返回结果的最大数量 Returns: 检索到的数据列表 """ end_time = get_utc_now() start_time = end_time - timedelta(days=days) conditions = QueryConditions( start_date=start_time, end_date=end_time, app_names=[app_name] if app_name else None, ) return self.search_by_conditions(conditions, limit) def search_by_keywords( self, keywords: list[str], days: int = 30, limit: int = 50 ) -> list[dict[str, Any]]: """ 按关键词检索记录 Args: keywords: 关键词列表 days: 检索最近多少天的记录 limit: 返回结果的最大数量 Returns: 检索到的数据列表 """ end_time = get_utc_now() start_time = end_time - timedelta(days=days) conditions = QueryConditions(start_date=start_time, end_date=end_time, keywords=keywords) return self.search_by_conditions(conditions, limit) def _apply_stats_conditions(self, query: Any, conditions: QueryConditions | None) -> Any: """应用统计查询条件""" if not conditions: return query if conditions.start_date: query = query.filter(col(Screenshot.created_at) >= conditions.start_date) if conditions.end_date: query = query.filter(col(Screenshot.created_at) <= conditions.end_date) if conditions.app_names: app_filters = [ col(Screenshot.app_name).ilike(f"%{app}%") for app in conditions.app_names ] query = query.filter(or_(*app_filters)) return query def _build_stats_result( self, total_count: int, app_stats: list[tuple[str, int]], time_range: Any, conditions: QueryConditions | None, ) -> dict[str, Any]: """构建统计结果""" return { "total_screenshots": total_count, "app_distribution": dict(app_stats), "time_range": { "earliest": time_range.earliest.isoformat() if time_range.earliest else None, "latest": time_range.latest.isoformat() if time_range.latest else None, }, "query_conditions": { "start_date": conditions.start_date.isoformat() if conditions and conditions.start_date else None, "end_date": conditions.end_date.isoformat() if conditions and conditions.end_date else None, "app_names": conditions.app_names if conditions else None, "keywords": conditions.keywords if conditions else [], }, } def get_statistics(self, conditions: QueryConditions | None = None) -> dict[str, Any]: """ 获取统计信息 Args: conditions: 可选的查询条件 Returns: 统计信息字典 """ try: logger.info("=== 数据库查询 - get_statistics ===") logger.info(f"统计查询条件: {conditions}") with get_session() as session: # 基础查询并应用条件 query = self._apply_stats_conditions(session.query(Screenshot), conditions) total_count = query.count() # 按应用分组统计 app_stats_query = session.query( col(Screenshot.app_name), func.count(col(Screenshot.id)).label("count") ).group_by(col(Screenshot.app_name)) app_stats_query = self._apply_stats_conditions(app_stats_query, conditions) app_stats = app_stats_query.all() # 时间范围 time_range = query.with_entities( func.min(col(Screenshot.created_at)).label("earliest"), func.max(col(Screenshot.created_at)).label("latest"), ).first() stats = self._build_stats_result(total_count, app_stats, time_range, conditions) # 记录统计结果 logger.info(f"统计结果: 总截图数={total_count}") app_dist = stats["app_distribution"] app_preview = dict(list(app_dist.items())[:MAX_APP_DISTRIBUTION_DISPLAY]) logger.info( f" 应用分布: {app_preview}{'...' if len(app_dist) > MAX_APP_DISTRIBUTION_DISPLAY else ''}" ) logger.info("=== 统计查询完成 ===") return stats except Exception as e: logger.error(f"统计信息获取失败: {e}") return { "total_screenshots": 0, "app_distribution": {}, "time_range": {"earliest": None, "latest": None}, "query_conditions": {}, } def _calculate_relevance( self, screenshot: Screenshot, ocr_text: str, conditions: QueryConditions ) -> float: """ 计算相关性得分 Args: screenshot: 截图对象 ocr_text: OCR文本 conditions: 查询条件 Returns: 相关性得分 (0.0 - 1.0) """ score = 0.0 # 应用名称匹配加分 if ( conditions.app_names and screenshot.app_name and any(app.lower() in screenshot.app_name.lower() for app in conditions.app_names) ): score += 0.3 # 关键词匹配加分 if conditions.keywords and ocr_text: text_lower = ocr_text.lower() keyword_matches = 0 for keyword in conditions.keywords: if keyword.lower() in text_lower: keyword_matches += 1 if keyword_matches > 0: score += 0.5 * (keyword_matches / len(conditions.keywords)) # 时间新近性加分 if screenshot.created_at: now = get_utc_now() time_diff = now - screenshot.created_at if time_diff.days < TIME_RECENCY_DAY_THRESHOLD: score += 0.2 elif time_diff.days < TIME_RECENCY_WEEK_THRESHOLD: score += 0.1 return min(score, 1.0) ================================================ FILE: lifetrace/llm/tavily_client.py ================================================ """Tavily API 客户端封装模块""" from typing import Any, cast from tavily import TavilyClient from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() class TavilyClientWrapper: """Tavily API 客户端封装类""" _instance = None _initialized = False def __new__(cls): """实现单例模式""" if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self): """初始化 Tavily 客户端""" if not TavilyClientWrapper._initialized: self._initialize_client() TavilyClientWrapper._initialized = True def _initialize_client(self): """内部方法:初始化或重新初始化客户端""" try: self.api_key = settings.tavily.api_key self.search_depth = settings.tavily.search_depth self.max_results = settings.tavily.max_results self.include_domains = settings.tavily.include_domains self.exclude_domains = settings.tavily.exclude_domains # 检查 API key 是否配置 invalid_values = [ "xxx", "YOUR_API_KEY_HERE", "YOUR_TAVILY_API_KEY_HERE", ] if not self.api_key or self.api_key in invalid_values: logger.warning("Tavily API Key 未配置或为默认占位符,联网搜索功能不可用") self.client = None return # 初始化 Tavily 客户端 self.client = TavilyClient(api_key=self.api_key) logger.info("Tavily 客户端初始化成功") except Exception as e: logger.error(f"Tavily 客户端初始化失败: {e}") self.client = None def is_available(self) -> bool: """检查 Tavily 客户端是否可用""" return self.client is not None def _get_client(self) -> TavilyClient: if self.client is None: raise RuntimeError("Tavily 客户端未配置或不可用") return self.client def search(self, query: str, **kwargs) -> dict[str, Any]: """ 执行 Tavily 搜索 Args: query: 搜索查询字符串 **kwargs: 额外的搜索参数 Returns: 包含搜索结果的字典,格式: { "results": [ { "url": "https://...", "title": "标题", "content": "内容摘要" }, ... ], "raw_response": {...} # 原始 Tavily 响应 } Raises: RuntimeError: 如果客户端未配置或不可用 Exception: 如果搜索请求失败 """ if not self.is_available(): raise RuntimeError("Tavily 客户端未配置或不可用,请在设置中填写 Tavily API Key") try: # 构建搜索参数 search_kwargs = { "query": query, "search_depth": kwargs.get("search_depth", self.search_depth), "max_results": kwargs.get("max_results", self.max_results), } # 添加域名过滤(如果配置了) if self.include_domains: search_kwargs["include_domains"] = self.include_domains if self.exclude_domains: search_kwargs["exclude_domains"] = self.exclude_domains # 合并用户提供的额外参数 search_kwargs.update( {k: v for k, v in kwargs.items() if k not in ["search_depth", "max_results"]} ) # 调用 Tavily search API client = self._get_client() response = client.search(**search_kwargs) response_data = cast("dict[str, Any]", response) # 格式化返回结果 results = [] if "results" in response_data: for item in response_data["results"]: results.append( { "url": item.get("url", ""), "title": item.get("title", ""), "content": item.get("content", ""), } ) return { "results": results, "raw_response": response_data, } except Exception as e: logger.error(f"Tavily 搜索失败: {e}") raise def research(self, query: str, **kwargs) -> dict[str, Any]: """ 执行 Tavily research(深度研究) Args: query: 研究查询字符串 **kwargs: 额外的研究参数 Returns: 包含研究结果的字典,格式与 search 相同 Raises: RuntimeError: 如果客户端未配置或不可用 Exception: 如果研究请求失败 """ if not self.is_available(): raise RuntimeError("Tavily 客户端未配置或不可用,请在设置中填写 Tavily API Key") try: # 构建研究参数 research_kwargs = { "query": query, "search_depth": kwargs.get("search_depth", "advanced"), "max_results": kwargs.get("max_results", self.max_results), } # 添加域名过滤(如果配置了) if self.include_domains: research_kwargs["include_domains"] = self.include_domains if self.exclude_domains: research_kwargs["exclude_domains"] = self.exclude_domains # 合并用户提供的额外参数 research_kwargs.update( {k: v for k, v in kwargs.items() if k not in ["search_depth", "max_results"]} ) # 调用 Tavily research API client = self._get_client() response = client.research(**research_kwargs) response_data = cast("dict[str, Any]", response) # 格式化返回结果 results = [] if "results" in response_data: for item in response_data["results"]: results.append( { "url": item.get("url", ""), "title": item.get("title", ""), "content": item.get("content", ""), } ) return { "results": results, "raw_response": response_data, } except Exception as e: logger.error(f"Tavily 研究失败: {e}") raise ================================================ FILE: lifetrace/llm/todo_extraction_service.py ================================================ """待办提取服务 从特定应用(微信、飞书等)的事件中提取待办事项 """ import json import re from datetime import datetime from typing import Any from lifetrace.llm.llm_client import LLMClient from lifetrace.llm.ocr_todo_extractor import OCRTodoExtractor from lifetrace.storage import event_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.time_parser import calculate_scheduled_time from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 需要特殊处理的应用列表(白名单) TODO_EXTRACTION_WHITELIST_APPS = ["微信", "WeChat", "飞书", "Feishu", "Lark", "钉钉", "DingTalk"] # 默认截图采样比例 DEFAULT_SCREENSHOT_SAMPLE_RATIO = 3 MIN_SCREENSHOTS = 1 MAX_SCREENSHOTS = 10 # 少于这个数量的截图不进行抽样,直接使用全部 NO_SAMPLE_THRESHOLD = 5 class TodoExtractionService: """待办提取服务""" def __init__(self): """初始化服务""" self.llm_client = LLMClient() self._ocr_extractor = OCRTodoExtractor(self.llm_client) def is_whitelist_app(self, app_name: str) -> bool: """判断是否为白名单应用 Args: app_name: 应用名称 Returns: 是否为白名单应用 """ if not app_name: return False app_name_lower = app_name.lower() return any( whitelist_app.lower() in app_name_lower for whitelist_app in TODO_EXTRACTION_WHITELIST_APPS ) def sample_screenshots( self, screenshots: list[dict[str, Any]], sample_ratio: int = DEFAULT_SCREENSHOT_SAMPLE_RATIO ) -> list[dict[str, Any]]: """ 对截图进行采样,选择代表性的截图 Args: screenshots: 截图列表(已按时间排序) sample_ratio: 采样比例(每N张选1张) Returns: 采样后的截图列表 """ if not screenshots: return [] total_count = len(screenshots) # 如果截图数量少于阈值,全部使用,不进行抽样 if total_count < NO_SAMPLE_THRESHOLD: logger.info( f"截图数量 {total_count} 少于{NO_SAMPLE_THRESHOLD}张,使用全部截图,不进行抽样" ) return screenshots # 计算采样后的数量 sampled_count = max(MIN_SCREENSHOTS, min(MAX_SCREENSHOTS, total_count // sample_ratio)) # 均匀采样 if sampled_count >= total_count: return screenshots step = total_count / sampled_count sampled = [] for i in range(sampled_count): index = int(i * step) if index < total_count: sampled.append(screenshots[index]) logger.info(f"从 {total_count} 张截图中采样了 {len(sampled)} 张") return sampled def extract_todos_from_event( self, event_id: int, screenshot_sample_ratio: int | None = None ) -> dict[str, Any]: """ 从事件中提取待办事项 Args: event_id: 事件ID screenshot_sample_ratio: 截图采样比例,如果不提供则使用默认值 Returns: 包含待办列表和元信息的字典 """ try: # 获取事件信息 event_info = event_mgr.get_event_summary(event_id) if not event_info: return { "event_id": event_id, "todos": [], "error_message": "事件不存在", } app_name = event_info.get("app_name") or "" if not self.is_whitelist_app(app_name): return { "event_id": event_id, "app_name": app_name, "todos": [], "error_message": f"应用 {app_name} 不在待办提取白名单中", } # 获取事件截图 screenshots = event_mgr.get_event_screenshots(event_id) if not screenshots: return { "event_id": event_id, "app_name": app_name, "todos": [], "error_message": "事件中没有可用的截图", } # 采样截图 sample_ratio = screenshot_sample_ratio or DEFAULT_SCREENSHOT_SAMPLE_RATIO sampled_screenshots = self.sample_screenshots(screenshots, sample_ratio) # 提取截图ID screenshot_ids = [s["id"] for s in sampled_screenshots] # 调用多模态模型提取待办 todos = self._call_vision_model( screenshot_ids=screenshot_ids, app_name=app_name, window_title=event_info.get("window_title", ""), event_start_time=event_info.get("start_time"), event_end_time=event_info.get("end_time"), ) # 解析时间信息并计算绝对时间 reference_time = ( event_info.get("end_time") or event_info.get("start_time") or get_utc_now() ) parsed_todos = [] for todo in todos: parsed_todo = self._parse_todo_time(todo, reference_time) if parsed_todo: parsed_todo["screenshot_ids"] = screenshot_ids parsed_todos.append(parsed_todo) return { "event_id": event_id, "app_name": app_name, "window_title": event_info.get("window_title"), "event_start_time": event_info.get("start_time"), "event_end_time": event_info.get("end_time"), "todos": parsed_todos, "screenshot_count": len(sampled_screenshots), } except Exception as e: logger.error(f"从事件 {event_id} 提取待办失败: {e}", exc_info=True) return { "event_id": event_id, "todos": [], "error_message": f"提取待办失败: {e!s}", } def _call_vision_model( self, screenshot_ids: list[int], app_name: str, window_title: str, event_start_time: datetime | None, event_end_time: datetime | None, ) -> list[dict[str, Any]]: """ 调用多模态模型分析截图,提取待办事项 Args: screenshot_ids: 截图ID列表 app_name: 应用名称 window_title: 窗口标题 event_start_time: 事件开始时间 event_end_time: 事件结束时间 Returns: 待办事项列表 """ if not self.llm_client.is_available(): logger.warning("LLM客户端不可用,无法提取待办") return [] try: # 格式化时间 start_str = ( event_start_time.strftime("%Y-%m-%d %H:%M:%S") if event_start_time else "未知" ) end_str = event_end_time.strftime("%Y-%m-%d %H:%M:%S") if event_end_time else "进行中" # 从配置文件加载提示词 system_prompt = get_prompt("todo_extraction", "system_assistant") user_prompt = get_prompt( "todo_extraction", "user_prompt", app_name=app_name, window_title=window_title, start_time=start_str, end_time=end_str, ) # 构建完整的提示词(包含system和user) full_prompt = f"{system_prompt}\n\n{user_prompt}" # 调用视觉模型 result = self.llm_client.vision_chat( screenshot_ids=screenshot_ids, prompt=full_prompt, temperature=0.3, # 使用较低温度以提高准确性 max_tokens=2000, ) response_text = result.get("response", "") if not response_text: logger.warning("视觉模型返回空响应") return [] # 解析LLM响应 todos = self._parse_llm_response(response_text) return todos except Exception as e: error_msg = str(e) # 检查是否是超时错误 is_timeout = "timeout" in error_msg.lower() or "timed out" in error_msg.lower() if is_timeout: logger.error( f"调用视觉模型提取待办超时: {error_msg}。" f"处理 {len(screenshot_ids)} 张截图可能需要更长时间," "建议减少截图数量或检查网络连接", exc_info=True, ) else: logger.error(f"调用视觉模型提取待办失败: {error_msg}", exc_info=True) return [] def _parse_llm_response(self, response_text: str) -> list[dict[str, Any]]: """ 解析LLM响应为待办事项列表 Args: response_text: LLM返回的文本 Returns: 待办事项列表 """ try: # 尝试提取JSON json_match = re.search(r"\{.*\}", response_text, re.DOTALL) if json_match: json_str = json_match.group(0) result = json.loads(json_str) if "todos" in result and isinstance(result["todos"], list): todos = [] for todo in result["todos"]: if "title" in todo and "time_info" in todo: todos.append(todo) return todos else: logger.warning("LLM响应中未找到JSON格式") return [] except json.JSONDecodeError as e: logger.error(f"解析LLM响应JSON失败: {e}\n原始响应: {response_text[:200]}") except Exception as e: logger.error(f"解析待办事项失败: {e}") return [] def _parse_todo_time( self, todo: dict[str, Any], reference_time: datetime ) -> dict[str, Any] | None: """ 解析待办的时间信息,计算绝对时间 Args: todo: 待办字典,包含time_info reference_time: 参考时间(事件开始或结束时间) Returns: 解析后的待办字典,包含scheduled_time字段 """ try: time_info = todo.get("time_info") if not time_info: logger.warning("待办项缺少time_info字段") return None # 计算绝对时间 scheduled_time = calculate_scheduled_time(time_info, reference_time) # 构建解析后的待办 parsed_todo = todo.copy() parsed_todo["scheduled_time"] = scheduled_time return parsed_todo except Exception as e: logger.error(f"解析待办时间失败: {e}") return None # ========= 主动 OCR 文本待办提取 ========= def extract_todos_from_ocr_text( self, ocr_result_id: int, text_content: str, app_name: str, window_title: str, ) -> dict[str, Any]: """基于主动 OCR 的纯文本进行待办提取。 委托给 OCRTodoExtractor 处理。 """ return self._ocr_extractor.extract_todos( ocr_result_id=ocr_result_id, text_content=text_content, app_name=app_name, window_title=window_title, ) # 全局实例 todo_extraction_service = TodoExtractionService() ================================================ FILE: lifetrace/llm/tools/__init__.py ================================================ """工具模块 - Agent 工具调用框架""" from lifetrace.llm.tools.base import Tool, ToolResult from lifetrace.llm.tools.registry import ToolRegistry from lifetrace.llm.tools.web_search_tool import WebSearchTool # 初始化工具注册表并注册工具 tool_registry = ToolRegistry() tool_registry.register(WebSearchTool()) __all__ = ["Tool", "ToolRegistry", "ToolResult", "tool_registry"] ================================================ FILE: lifetrace/llm/tools/base.py ================================================ """工具基类定义""" from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any @dataclass class ToolResult: """工具执行结果""" success: bool content: str # 工具返回的内容 metadata: dict[str, Any] | None = None # 额外元数据(如来源链接) error: str | None = None class Tool(ABC): """工具基类""" @property @abstractmethod def name(self) -> str: """工具名称""" pass @property @abstractmethod def description(self) -> str: """工具描述,用于 LLM 选择工具""" pass @property @abstractmethod def parameters_schema(self) -> dict: """工具参数 JSON Schema,用于 LLM 生成参数""" pass @abstractmethod def execute(self, **kwargs) -> ToolResult: """执行工具""" pass def is_available(self) -> bool: """检查工具是否可用""" return True ================================================ FILE: lifetrace/llm/tools/registry.py ================================================ """工具注册表""" from typing import ClassVar from lifetrace.llm.tools.base import Tool from lifetrace.util.logging_config import get_logger logger = get_logger() class ToolRegistry: """工具注册表(单例)""" _instance: ClassVar["ToolRegistry | None"] = None _tools: ClassVar[dict[str, Tool]] = {} def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def register(self, tool: Tool): """注册工具""" self._tools[tool.name] = tool logger.info(f"注册工具: {tool.name}") def get_tool(self, name: str) -> Tool | None: """获取工具""" return self._tools.get(name) def get_available_tools(self) -> list[Tool]: """获取所有可用工具""" return [tool for tool in self._tools.values() if tool.is_available()] def get_tools_schema(self) -> list[dict]: """获取所有工具的 JSON Schema(用于 LLM)""" return [ { "name": tool.name, "description": tool.description, "parameters": tool.parameters_schema, } for tool in self.get_available_tools() ] ================================================ FILE: lifetrace/llm/tools/web_search_tool.py ================================================ """联网搜索工具实现""" from lifetrace.llm.tavily_client import TavilyClientWrapper from lifetrace.llm.tools.base import Tool, ToolResult from lifetrace.util.logging_config import get_logger logger = get_logger() class WebSearchTool(Tool): """联网搜索工具""" def __init__(self): """初始化联网搜索工具""" self.tavily_client = TavilyClientWrapper() @property def name(self) -> str: return "web_search" @property def description(self) -> str: return ( "使用联网搜索工具查找最新的网络信息。" "适用于需要实时信息、最新资讯、技术文档、新闻等场景。" "当用户询问当前事件、最新技术、实时数据时应该使用此工具。" ) @property def parameters_schema(self) -> dict: return { "type": "object", "properties": { "query": { "type": "string", "description": "搜索查询字符串", }, }, "required": ["query"], } def execute(self, **kwargs) -> ToolResult: """执行搜索""" try: query = kwargs.get("query") if not isinstance(query, str) or not query.strip(): return ToolResult( success=False, content="", error="缺少有效的搜索查询参数", ) if not self.tavily_client.is_available(): return ToolResult( success=False, content="", error="Tavily API 未配置,无法使用联网搜索", ) # 执行 Tavily 搜索 logger.info(f"[WebSearchTool] 执行搜索: {query}") result = self.tavily_client.search(query) results = result.get("results", []) if not results: return ToolResult( success=True, content="未找到相关搜索结果。", metadata={"results": []}, ) # 格式化搜索结果 formatted_results = [] sources = [] for idx, item in enumerate(results, start=1): title = item.get("title", "无标题") url = item.get("url", "") content = item.get("content", "") formatted_results.append( f"[{idx}] {title}\nURL: {url}\n摘要: {content}", ) sources.append({"title": title, "url": url}) content = "\n\n".join(formatted_results) logger.info( f"[WebSearchTool] 搜索完成,找到 {len(results)} 个结果", ) return ToolResult( success=True, content=content, metadata={"results": results, "sources": sources}, ) except Exception as e: logger.error(f"[WebSearchTool] 执行失败: {e}", exc_info=True) return ToolResult( success=False, content="", error=str(e), ) def is_available(self) -> bool: return self.tavily_client.is_available() ================================================ FILE: lifetrace/llm/vector_db.py ================================================ """向量数据库模块 提供文本嵌入、向量存储、语义检索和重排序功能。 支持与现有 SQLite 数据库并行使用。 """ import hashlib from typing import Any, cast from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_vector_db_dir from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now logger = get_logger() try: import chromadb import numpy as np from chromadb.config import Settings from sentence_transformers import CrossEncoder, SentenceTransformer except ImportError as e: logger.warning(f"Vector database dependencies not installed: {e}") logger.warning("Please install with: pip install -r requirements_vector.txt") SentenceTransformer = None CrossEncoder = None chromadb = None np = None Settings = None class VectorDatabase: """向量数据库管理器 提供文本嵌入、向量存储和语义检索功能。 使用 ChromaDB 作为向量数据库后端。 """ def __init__(self): """初始化向量数据库""" self.logger = logger # 检查依赖 if not self._check_dependencies(): raise ImportError("Vector database dependencies not available") # 初始化模型和数据库 self.embedding_model = None self.cross_encoder = None self.chroma_client = None self.collection = None # 配置参数 self.vector_db_path = get_vector_db_dir() self.embedding_model_name = settings.get("vector_db.embedding_model") self.cross_encoder_model_name = settings.get("vector_db.rerank_model") self.collection_name = settings.vector_db.collection_name # 初始化 self._initialize() def _check_dependencies(self) -> bool: """检查依赖是否可用""" return all( [ SentenceTransformer is not None, CrossEncoder is not None, chromadb is not None, np is not None, Settings is not None, ] ) def _initialize(self): """初始化模型和数据库""" try: # 创建数据目录 self.vector_db_path.mkdir(parents=True, exist_ok=True) # 初始化嵌入模型 if self.embedding_model_name: if SentenceTransformer is None: raise RuntimeError("SentenceTransformer not available") self.logger.info(f"Loading embedding model: {self.embedding_model_name}") self.embedding_model = SentenceTransformer(self.embedding_model_name) else: self.logger.info("Skipping embedding model initialization (multimodal mode)") self.embedding_model = None # 初始化 ChromaDB self.logger.info(f"Initializing ChromaDB at: {self.vector_db_path}") if chromadb is None: raise RuntimeError("ChromaDB dependency not available") if Settings is None: raise RuntimeError("ChromaDB Settings not available") self.chroma_client = chromadb.PersistentClient( path=str(self.vector_db_path), settings=Settings(anonymized_telemetry=False, allow_reset=True), ) # 获取或创建集合 self.collection = self.chroma_client.get_or_create_collection( name=self.collection_name, metadata={"description": "LifeTrace OCR text embeddings"}, ) self.logger.info("Vector database initialized successfully") except Exception as e: self.logger.error(f"Failed to initialize vector database: {e}") raise def _get_cross_encoder(self) -> Any: """延迟加载交叉编码器""" if self.cross_encoder is None: self.logger.info(f"Loading cross-encoder model: {self.cross_encoder_model_name}") if CrossEncoder is None: raise RuntimeError("CrossEncoder not available") self.cross_encoder = CrossEncoder(self.cross_encoder_model_name) return self.cross_encoder def embed_text(self, text: str) -> list[float]: """将文本转换为向量嵌入 Args: text: 输入文本 Returns: 文本的向量嵌入 """ if not text or not text.strip(): return [] if not self.embedding_model: raise RuntimeError("Embedding model not available (multimodal mode)") try: embedding_model = self.embedding_model embedding = embedding_model.encode(text.strip(), normalize_embeddings=True) return embedding.tolist() except Exception as e: self.logger.error(f"Failed to embed text: {e}") return [] def add_document(self, doc_id: str, text: str, metadata: dict[str, Any] | None = None) -> bool: """添加文档到向量数据库 Args: doc_id: 文档唯一标识符 text: 文档文本内容 metadata: 文档元数据 Returns: 是否添加成功 """ if not text or not text.strip(): self.logger.warning(f"Empty text for document {doc_id}") return False try: if self.collection is None: raise RuntimeError("Vector collection not initialized") collection = self.collection # 生成嵌入 embedding = self.embed_text(text) if not embedding: return False # 准备元数据 doc_metadata = { "timestamp": get_utc_now().isoformat(), "text_length": len(text), "text_hash": hashlib.md5(text.encode(), usedforsecurity=False).hexdigest(), } if metadata: doc_metadata.update(metadata) # 过滤掉 None 值(ChromaDB 不接受 None) doc_metadata = {k: v for k, v in doc_metadata.items() if v is not None} # 添加到集合 collection.add( documents=[text], embeddings=[embedding], metadatas=[doc_metadata], ids=[doc_id], ) self.logger.debug(f"Added document {doc_id} to vector database") return True except Exception as e: self.logger.error(f"Failed to add document {doc_id}: {e}") return False def add_document_with_embedding( self, doc_id: str, text: str, embedding: list[float], metadata: dict[str, Any] | None = None, ) -> bool: """使用预计算的嵌入向量添加文档到向量数据库 Args: doc_id: 文档唯一标识符 text: 文档文本内容 embedding: 预计算的嵌入向量 metadata: 文档元数据 Returns: 是否添加成功 """ if not text or not text.strip(): self.logger.warning(f"Empty text for document {doc_id}") return False if not embedding: self.logger.warning(f"Empty embedding for document {doc_id}") return False try: if self.collection is None: raise RuntimeError("Vector collection not initialized") collection = self.collection # 准备元数据 doc_metadata = { "timestamp": get_utc_now().isoformat(), "text_length": len(text), "text_hash": hashlib.md5(text.encode(), usedforsecurity=False).hexdigest(), } if metadata: doc_metadata.update(metadata) # 过滤掉 None 值(ChromaDB 不接受 None) doc_metadata = {k: v for k, v in doc_metadata.items() if v is not None} # 添加到集合 collection.add( documents=[text], embeddings=[embedding], metadatas=[doc_metadata], ids=[doc_id], ) self.logger.debug(f"Added document {doc_id} with pre-computed embedding") return True except Exception as e: self.logger.error(f"Failed to add document {doc_id} with embedding: {e}") return False def update_document( self, doc_id: str, text: str, metadata: dict[str, Any] | None = None ) -> bool: """更新文档 Args: doc_id: 文档唯一标识符 text: 新的文档文本内容 metadata: 新的文档元数据 Returns: 是否更新成功 """ try: # 先删除旧文档 self.delete_document(doc_id) # 添加新文档 return self.add_document(doc_id, text, metadata) except Exception as e: self.logger.error(f"Failed to update document {doc_id}: {e}") return False def delete_document(self, doc_id: str) -> bool: """删除文档 Args: doc_id: 文档唯一标识符 Returns: 是否删除成功 """ try: if self.collection is None: raise RuntimeError("Vector collection not initialized") self.collection.delete(ids=[doc_id]) self.logger.debug(f"Deleted document {doc_id} from vector database") return True except Exception as e: self.logger.error(f"Failed to delete document {doc_id}: {e}") return False def search( self, query: str, top_k: int = 10, where: dict[str, Any] | None = None ) -> list[dict[str, Any]]: """语义搜索 Args: query: 查询文本 top_k: 返回结果数量 where: 元数据过滤条件 Returns: 搜索结果列表,每个结果包含 id, document, metadata, distance """ if not query or not query.strip(): return [] try: if self.collection is None: raise RuntimeError("Vector collection not initialized") # 生成查询嵌入 query_embedding = self.embed_text(query) if not query_embedding: return [] # 清理和验证 where 条件 cleaned_where = self._clean_where_clause(where) # 执行搜索 results = self.collection.query( query_embeddings=[query_embedding], n_results=top_k, where=cleaned_where ) results_dict = cast("dict[str, Any]", results) ids = results_dict.get("ids") or [[]] documents = results_dict.get("documents") or [[]] metadatas = results_dict.get("metadatas") or [[]] distances = results_dict.get("distances") or [] # 格式化结果 formatted_results = [] for i in range(len(ids[0])): formatted_results.append( { "id": ids[0][i], "document": documents[0][i], "metadata": (metadatas[0][i] if metadatas[0] else {}), "distance": (distances[0][i] if distances else None), } ) self.logger.debug(f"Found {len(formatted_results)} results for query: {query[:50]}...") return formatted_results except Exception as e: self.logger.error(f"Failed to search: {e}") return [] def _clean_where_clause(self, where: dict[str, Any] | None) -> dict[str, Any] | None: """清理和验证 where 条件,移除空对象和无效操作符 Args: where: 原始的 where 条件 Returns: 清理后的 where 条件,如果没有有效条件则返回 None """ if not where: return None cleaned = {} for key, value in where.items(): # 跳过空对象或无效值 if value is None or (isinstance(value, dict) and not value): continue # 如果是字典,递归清理 if isinstance(value, dict): cleaned_value = self._clean_where_clause(value) if cleaned_value: cleaned[key] = cleaned_value else: cleaned[key] = value return cleaned if cleaned else None def rerank( self, query: str, documents: list[str], top_k: int | None = None ) -> list[tuple[str, float]]: """使用交叉编码器重排序文档 Args: query: 查询文本 documents: 文档列表 top_k: 返回的文档数量,None 表示返回全部 Returns: 重排序后的文档列表,每个元素为 (document, score) """ if not query or not documents: return [] try: cross_encoder = self._get_cross_encoder() # 构建查询-文档对 pairs = [(query, doc) for doc in documents] # 计算相关性分数 scores = cross_encoder.predict(pairs) # 排序 scored_docs = list(zip(documents, scores, strict=False)) scored_docs.sort(key=lambda x: x[1], reverse=True) # 返回指定数量 if top_k is not None: scored_docs = scored_docs[:top_k] self.logger.debug(f"Reranked {len(documents)} documents, returning {len(scored_docs)}") return scored_docs except Exception as e: self.logger.error(f"Failed to rerank documents: {e}") return [(doc, 0.0) for doc in documents] def search_and_rerank( self, query: str, retrieve_k: int = 20, rerank_k: int = 5, where: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: """搜索并重排序 Args: query: 查询文本 retrieve_k: 初始检索数量 rerank_k: 重排序后返回数量 where: 元数据过滤条件 Returns: 重排序后的搜索结果 """ # 初始检索 search_results = self.search(query, retrieve_k, where) if not search_results: return [] # 提取文档文本 documents = [result["document"] for result in search_results] # 重排序 reranked_docs = self.rerank(query, documents, rerank_k) # 构建最终结果 final_results = [] for doc, score in reranked_docs: # 找到对应的原始结果 for result in search_results: if result["document"] == doc: result["rerank_score"] = float(score) final_results.append(result) break return final_results def get_collection_stats(self) -> dict[str, Any]: """获取集合统计信息 Returns: 集合统计信息 """ try: if self.collection is None: raise RuntimeError("Vector collection not initialized") count = self.collection.count() return { "collection_name": self.collection_name, "document_count": count, "embedding_model": self.embedding_model_name, "cross_encoder_model": self.cross_encoder_model_name, "vector_db_path": str(self.vector_db_path), } except Exception as e: self.logger.error(f"Failed to get collection stats: {e}") return {} def reset_collection(self) -> bool: """重置集合(删除所有数据) Returns: 是否重置成功 """ try: if self.chroma_client is None: raise RuntimeError("Chroma client not initialized") self.chroma_client.delete_collection(self.collection_name) self.collection = self.chroma_client.create_collection( name=self.collection_name, metadata={"description": "LifeTrace OCR text embeddings"}, ) self.logger.info(f"Reset collection {self.collection_name}") return True except Exception as e: self.logger.error(f"Failed to reset collection: {e}") return False def create_vector_db() -> VectorDatabase | None: """创建向量数据库实例 Returns: 向量数据库实例,如果依赖不可用则返回 None """ # 检查依赖 if not all([SentenceTransformer, CrossEncoder, chromadb, np, Settings]): logger.warning("Vector database dependencies not available") return None # 检查是否启用向量数据库 if not settings.vector_db.enabled: logger.info("Vector database is disabled in configuration") return None try: return VectorDatabase() except ImportError: logger.warning("Vector database not available, skipping initialization") return None except Exception as e: logger.error(f"Failed to create vector database: {e}") return None ================================================ FILE: lifetrace/llm/vector_service.py ================================================ """向量数据库服务模块 提供 OCR 结果的向量化存储和语义搜索服务。 与现有的 SQLite 数据库并行工作。 """ from typing import Any from lifetrace.llm.vector_db import create_vector_db from lifetrace.storage import event_mgr, get_session from lifetrace.storage.models import Event, OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() class VectorService: """向量数据库服务 负责将 OCR 结果存储到向量数据库,并提供语义搜索功能。 """ def __init__(self): """初始化向量服务""" self.logger = logger # 初始化向量数据库 self.vector_db = create_vector_db() if self.vector_db is None: self.logger.warning("Vector database not available") self.enabled = False else: self.enabled = True self.logger.info("Vector service initialized successfully") def is_enabled(self) -> bool: """检查向量服务是否可用""" return self.enabled and self.vector_db is not None def _require_vector_db(self): if self.vector_db is None: raise RuntimeError("Vector database not initialized") return self.vector_db def add_ocr_result(self, ocr_result: OCRResult, screenshot: Screenshot | None = None) -> bool: """添加 OCR 结果到向量数据库 Args: ocr_result: OCR 结果对象 screenshot: 关联的截图对象(可选) Returns: 是否添加成功 """ if not self.is_enabled(): return False if not ocr_result.text_content or not ocr_result.text_content.strip(): self.logger.debug(f"Skipping empty OCR result {ocr_result.id}") return False try: vector_db = self._require_vector_db() # 构建文档 ID doc_id = f"ocr_{ocr_result.id}" # 构建元数据 metadata = { "ocr_result_id": ocr_result.id, "screenshot_id": ocr_result.screenshot_id, "confidence": ocr_result.confidence, "language": ocr_result.language or "unknown", "processing_time": ocr_result.processing_time, "created_at": ( ocr_result.created_at.isoformat() if ocr_result.created_at else None ), "text_length": len(ocr_result.text_content), } # 添加截图与事件相关信息 if screenshot: metadata.update( { "screenshot_path": screenshot.file_path, "screenshot_timestamp": ( screenshot.created_at.isoformat() if screenshot.created_at else None ), "application": screenshot.app_name, "window_title": screenshot.window_title, "width": screenshot.width, "height": screenshot.height, "event_id": getattr(screenshot, "event_id", None), } ) # 添加到向量数据库 success = vector_db.add_document( doc_id=doc_id, text=ocr_result.text_content, metadata=metadata ) if success: self.logger.debug(f"Added OCR result {ocr_result.id} to vector database") else: self.logger.warning(f"Failed to add OCR result {ocr_result.id} to vector database") return success except Exception as e: self.logger.error(f"Error adding OCR result {ocr_result.id} to vector database: {e}") return False def update_ocr_result( self, ocr_result: OCRResult, screenshot: Screenshot | None = None ) -> bool: """更新向量数据库中的 OCR 结果 Args: ocr_result: OCR 结果对象 screenshot: 关联的截图对象(可选) Returns: 是否更新成功 """ if not self.is_enabled(): return False try: vector_db = self._require_vector_db() doc_id = f"ocr_{ocr_result.id}" # 构建元数据 metadata = { "ocr_result_id": ocr_result.id, "screenshot_id": ocr_result.screenshot_id, "confidence": ocr_result.confidence, "language": ocr_result.language or "unknown", "processing_time": ocr_result.processing_time, "created_at": ( ocr_result.created_at.isoformat() if ocr_result.created_at else None ), "updated_at": get_utc_now().isoformat(), "text_length": len(ocr_result.text_content or ""), } if screenshot: metadata.update( { "screenshot_path": screenshot.file_path, "screenshot_timestamp": ( screenshot.created_at.isoformat() if screenshot.created_at else None ), "application": screenshot.app_name, "window_title": screenshot.window_title, "width": screenshot.width, "height": screenshot.height, } ) success = vector_db.update_document( doc_id=doc_id, text=ocr_result.text_content or "", metadata=metadata ) if success: self.logger.debug(f"Updated OCR result {ocr_result.id} in vector database") return success except Exception as e: self.logger.error(f"Error updating OCR result {ocr_result.id} in vector database: {e}") return False def delete_ocr_result(self, ocr_result_id: int) -> bool: """从向量数据库中删除 OCR 结果 Args: ocr_result_id: OCR 结果 ID Returns: 是否删除成功 """ if not self.is_enabled(): return False try: vector_db = self._require_vector_db() doc_id = f"ocr_{ocr_result_id}" success = vector_db.delete_document(doc_id) if success: self.logger.debug(f"Deleted OCR result {ocr_result_id} from vector database") return success except Exception as e: self.logger.error( f"Error deleting OCR result {ocr_result_id} from vector database: {e}" ) return False def _compute_score(self, result: dict[str, Any]) -> float: """计算统一的相似度分数""" if "rerank_score" in result: return result["rerank_score"] if "distance" in result: return max(0, 1 - result["distance"]) return 0.0 def _fetch_db_records( self, ocr_result_id: int | None, screenshot_id: int | None ) -> dict[str, Any]: """获取数据库中的 OCR 和截图记录""" result: dict[str, Any] = {} if not ocr_result_id: return result with get_session() as session: ocr_result = session.query(OCRResult).filter(col(OCRResult.id) == ocr_result_id).first() if ocr_result: result["ocr_result"] = { "id": ocr_result.id, "text_content": ocr_result.text_content, "confidence": ocr_result.confidence, "language": ocr_result.language, "processing_time": ocr_result.processing_time, "created_at": ( ocr_result.created_at.isoformat() if ocr_result.created_at else None ), } if screenshot_id: screenshot = ( session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first() ) if screenshot: result["screenshot"] = { "id": screenshot.id, "file_path": screenshot.file_path, "app_name": screenshot.app_name, "window_title": screenshot.window_title, "width": screenshot.width, "height": screenshot.height, "created_at": ( screenshot.created_at.isoformat() if screenshot.created_at else None ), } return result def _enhance_result(self, result: dict[str, Any]) -> dict[str, Any]: """增强单个搜索结果""" enhanced = result.copy() enhanced["score"] = self._compute_score(result) metadata = result.get("metadata", {}) try: db_records = self._fetch_db_records( metadata.get("ocr_result_id"), metadata.get("screenshot_id") ) enhanced.update(db_records) except Exception as db_error: self.logger.warning(f"无法获取相关数据库记录: {db_error}") return enhanced def semantic_search( self, query: str, top_k: int = 10, use_rerank: bool = True, retrieve_k: int | None = None, filters: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: """语义搜索 OCR 结果 Args: query: 搜索查询 top_k: 返回结果数量 use_rerank: 是否使用重排序 retrieve_k: 初始检索数量(用于重排序) filters: 元数据过滤条件 Returns: 搜索结果列表 """ if not self.is_enabled() or not query or not query.strip(): return [] try: vector_db = self._require_vector_db() if use_rerank: if retrieve_k is None: retrieve_k = min(top_k * 3, 50) results = vector_db.search_and_rerank( query=query, retrieve_k=retrieve_k, rerank_k=top_k, where=filters ) else: results = vector_db.search(query=query, top_k=top_k, where=filters) return [self._enhance_result(r) for r in results] except Exception as e: self.logger.error(f"语义搜索失败: {e}") return [] # 事件级索引与搜索 def upsert_event_document(self, event_id: int) -> bool: """将事件聚合文本写入向量库,文档ID: event_{event_id}""" if not self.is_enabled(): return False try: vector_db = self._require_vector_db() # 聚合事件文本 event_text = event_mgr.get_event_text(event_id) or "" if not event_text or not event_text.strip(): self.logger.debug(f"事件{event_id}无文本,跳过索引") return False # 元数据(基本信息) # 为了简化,这里不再重复查事件信息,向上层调用者可扩展 doc_id = f"event_{event_id}" return vector_db.update_document(doc_id, event_text, {"event_id": event_id}) except Exception as e: self.logger.error(f"事件{event_id}写入向量库失败: {e}") return False def _aggregate_event_scores(self, results: list[dict[str, Any]]) -> dict[int, dict[str, float]]: """按 event_id 聚合结果,保留最高分数""" event_scores: dict[int, dict[str, float]] = {} for result in results: event_id = result.get("metadata", {}).get("event_id") if not event_id: continue semantic_score = self._compute_score(result) if event_id not in event_scores or semantic_score > event_scores[event_id]["score"]: event_scores[event_id] = { "score": semantic_score, "distance": result.get("distance", 1.0), } return event_scores def _fetch_event_details( self, event_id: int, score_info: dict[str, float] ) -> dict[str, Any] | None: """获取事件详细信息""" with get_session() as session: event = session.query(Event).filter(col(Event.id) == event_id).first() if not event: return None screenshot_count = ( session.query(Screenshot).filter(col(Screenshot.event_id) == event_id).count() ) first_screenshot = ( session.query(Screenshot) .filter(col(Screenshot.event_id) == event_id) .order_by(col(Screenshot.created_at).asc()) .first() ) return { "id": event.id, "app_name": event.app_name, "window_title": event.window_title, "start_time": event.start_time.isoformat() if event.start_time else None, "end_time": event.end_time.isoformat() if event.end_time else None, "screenshot_count": screenshot_count, "first_screenshot_id": first_screenshot.id if first_screenshot else None, "semantic_score": score_info["score"], "distance": score_info["distance"], } def semantic_search_events(self, query: str, top_k: int = 10) -> list[dict[str, Any]]: """对事件文档进行语义搜索(基于 event_{id} 文档)""" if not self.is_enabled(): return [] try: vector_db = self._require_vector_db() search_limit = max(top_k * 3, 50) all_results = vector_db.search(query=query, top_k=search_limit) if not all_results: return [] event_scores = self._aggregate_event_scores(all_results) event_results = [] for event_id, score_info in event_scores.items(): try: event_data = self._fetch_event_details(event_id, score_info) if event_data: event_results.append(event_data) except Exception as db_error: self.logger.warning(f"获取事件{event_id}详细信息失败: {db_error}") event_results.sort(key=lambda x: x.get("semantic_score", 0.0), reverse=True) return event_results[:top_k] except Exception as e: self.logger.error(f"事件语义搜索失败: {e}") return [] def _should_reset_vector_db( self, total_ocr_count: int, vector_doc_count: int, force_reset: bool ) -> bool: """判断是否需要重置向量数据库""" if force_reset: return True # SQLite 为空但向量数据库不为空 return total_ocr_count == 0 and vector_doc_count > 0 def _sync_ocr_results(self, session, ocr_results: list) -> int: """同步 OCR 结果到向量数据库""" synced_count = 0 for ocr_result in ocr_results: screenshot = ( session.query(Screenshot) .filter(col(Screenshot.id) == ocr_result.screenshot_id) .first() ) if screenshot is None: self.logger.warning(f"Screenshot not found for OCR result {ocr_result.id}") continue if self.add_ocr_result(ocr_result, screenshot): synced_count += 1 if synced_count % 100 == 0: self.logger.info(f"Synced {synced_count} OCR results to vector database") return synced_count def sync_from_database(self, limit: int | None = None, force_reset: bool = False) -> int: """从 SQLite 数据库同步 OCR 结果到向量数据库 Args: limit: 同步的最大记录数,None 表示同步全部 force_reset: 是否先重置向量数据库 Returns: 同步的记录数 """ if not self.is_enabled(): return 0 try: with get_session() as session: total_ocr_count = session.query(OCRResult).count() vector_db = self._require_vector_db() vector_doc_count = vector_db.get_collection_stats().get("document_count", 0) self.logger.info( f"SQLite: {total_ocr_count} OCR results, Vector: {vector_doc_count} documents" ) if self._should_reset_vector_db(total_ocr_count, vector_doc_count, force_reset): self.logger.info("Resetting vector database") self.reset() if total_ocr_count == 0: return 0 if total_ocr_count == 0: self.logger.info("Both databases are empty, no sync needed") return 0 query = session.query(OCRResult).join( Screenshot, col(OCRResult.screenshot_id) == col(Screenshot.id) ) if limit: query = query.limit(limit) ocr_results = query.all() if not limit and len(ocr_results) != vector_doc_count: self.logger.info("Document count mismatch, resetting vector database") self.reset() synced_count = self._sync_ocr_results(session, ocr_results) self.logger.info( f"Completed sync: {synced_count} OCR results added to vector database" ) return synced_count except Exception as e: self.logger.error(f"Error syncing from database: {e}") return 0 def get_stats(self) -> dict[str, Any]: """获取向量数据库统计信息 Returns: 统计信息字典 """ if not self.is_enabled(): return {"enabled": False, "reason": "Vector database not available"} try: vector_db = self._require_vector_db() stats = vector_db.get_collection_stats() stats["enabled"] = True return stats except Exception as e: self.logger.error(f"Error getting vector database stats: {e}") return {"enabled": True, "error": str(e)} def reset(self) -> bool: """重置向量数据库 Returns: 是否重置成功 """ if not self.is_enabled(): return False try: vector_db = self._require_vector_db() success = vector_db.reset_collection() if success: self.logger.info("Vector database reset successfully") return success except Exception as e: self.logger.error(f"Error resetting vector database: {e}") return False def create_vector_service() -> VectorService: """创建向量服务实例 Returns: 向量服务实例 """ return VectorService() ================================================ FILE: lifetrace/llm/web_search_service.py ================================================ """联网搜索服务模块 - 整合 Tavily 和 LLM""" from collections.abc import Generator from lifetrace.llm.llm_client import LLMClient from lifetrace.llm.tavily_client import TavilyClientWrapper from lifetrace.util.language import get_language_instruction from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt logger = get_logger() class WebSearchService: """联网搜索服务,整合 Tavily 搜索结果和 LLM 生成""" def __init__(self): """初始化联网搜索服务""" self.tavily_client = TavilyClientWrapper() self.llm_client = LLMClient() logger.info("联网搜索服务初始化完成") def build_search_prompt( self, query: str, tavily_result: dict, todo_context: str | None = None, lang: str = "zh" ) -> list[dict[str, str]]: """ 构建用于 LLM 的搜索提示词 Args: query: 用户查询 tavily_result: Tavily 搜索结果 todo_context: 待办事项上下文(可选) lang: 语言代码 ("zh" 或 "en") Returns: LLM messages 列表 """ # 获取 system prompt system_prompt = get_prompt("web_search", "system") # 注入语言指令 system_prompt += get_language_instruction(lang) # 格式化搜索结果 results = tavily_result.get("results", []) if not results: sources_context = "未找到相关搜索结果。" else: sources_list = [] for idx, item in enumerate(results, start=1): url = item.get("url", "") title = item.get("title", "无标题") content = item.get("content", "") sources_list.append(f"[{idx}] {title}\nURL: {url}\n摘要: {content}") sources_context = "\n\n".join(sources_list) # 构建用户提示词,包含待办上下文(如果提供) user_prompt_parts = [] if todo_context: user_prompt_parts.append("用户当前的待办事项上下文:") user_prompt_parts.append(todo_context) user_prompt_parts.append("") # 获取 user prompt 模板并格式化 base_user_prompt = get_prompt( "web_search", "user_template", query=query, sources_context=sources_context ) user_prompt_parts.append(base_user_prompt) user_prompt = "\n".join(user_prompt_parts) return [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] def _parse_message_with_context(self, message: str) -> tuple[str, str | None]: """ 解析包含待办上下文的消息,提取用户查询和上下文 Args: message: 完整的消息(可能包含待办上下文) Returns: (用户查询, 待办上下文) 元组 """ # 尝试匹配 "用户输入:" 或 "User input:" 标记 # 支持中英文标签 markers = ["用户输入:", "User input:"] todo_context = None actual_query = message expected_parts = 2 for marker in markers: if marker in message: parts = message.split(marker, 1) if len(parts) == expected_parts: # 提取待办上下文(标记前的部分) context_part = parts[0].strip() # 提取用户查询(标记后的部分) actual_query = parts[1].strip() # 如果上下文部分不为空,则作为待办上下文 if context_part: todo_context = context_part break return actual_query, todo_context def stream_answer_with_sources(self, query: str, lang: str = "zh") -> Generator[str]: """ 流式生成带来源的回答 Args: query: 用户查询(可能包含待办上下文) lang: 语言代码 ("zh" 或 "en") Yields: 文本块(逐 token) """ try: # 解析消息,提取实际查询和待办上下文 actual_query, todo_context = self._parse_message_with_context(query) # 检查 Tavily 是否可用 if not self.tavily_client.is_available(): error_msg = "当前未配置联网搜索服务,请在设置中填写 Tavily API Key。" yield error_msg return # 执行 Tavily 搜索(使用实际查询) logger.info(f"开始执行 Tavily 搜索: {actual_query}") if todo_context: logger.info("检测到待办上下文,将在生成回答时使用") tavily_result = self.tavily_client.search(actual_query) logger.info(f"Tavily 搜索完成,找到 {len(tavily_result.get('results', []))} 个结果") # 检查 LLM 是否可用 if not self.llm_client.is_available(): # LLM 不可用时,返回格式化后的搜索结果 fallback_text = self._format_fallback_response(actual_query, tavily_result) yield fallback_text return # 构建 prompt(包含待办上下文和语言) messages = self.build_search_prompt(actual_query, tavily_result, todo_context, lang) # 流式调用 LLM logger.info("开始流式生成回答") output_chunks: list[str] = [] for text in self.llm_client.stream_chat(messages=messages, temperature=0.7): if text: output_chunks.append(text) yield text logger.info("流式生成完成") except RuntimeError as e: # Tavily 配置错误 error_msg = str(e) logger.error(f"联网搜索失败: {error_msg}") yield error_msg except Exception as e: # 其他错误 logger.error(f"联网搜索处理失败: {e}", exc_info=True) yield f"联网搜索处理时出现错误: {e!s}" def _format_fallback_response(self, query: str, tavily_result: dict) -> str: """ 当 LLM 不可用时的备用响应格式 Args: query: 用户查询 tavily_result: Tavily 搜索结果 Returns: 格式化的响应文本 """ results = tavily_result.get("results", []) if not results: return f"抱歉,未找到与 '{query}' 相关的搜索结果。" response_parts = [ f"根据您的查询 '{query}',我找到了以下信息:", "", ] # 列出搜索结果 for idx, item in enumerate(results, start=1): title = item.get("title", "无标题") url = item.get("url", "") content = item.get("content", "") response_parts.append(f"{idx}. {title}") response_parts.append(f" URL: {url}") if content: response_parts.append(f" 摘要: {content[:200]}...") response_parts.append("") response_parts.append("\nSources:") for idx, item in enumerate(results, start=1): title = item.get("title", "无标题") url = item.get("url", "") response_parts.append(f"{idx}. {title} ({url})") return "\n".join(response_parts) ================================================ FILE: lifetrace/migrations/MIGRATIONS.md ================================================ Alembic 数据库迁移目录 此目录包含 Alembic 数据库迁移脚本。 ## 常用命令 ### 生成新的迁移脚本 ```bash cd lifetrace alembic revision --autogenerate -m "描述迁移内容" ``` ### 应用所有迁移 ```bash alembic upgrade head ``` ### 回滚迁移 ```bash alembic downgrade -1 # 回滚一个版本 alembic downgrade base # 回滚到初始状态 ``` ### 查看当前版本 ```bash alembic current ``` ### 查看迁移历史 ```bash alembic history ``` ### 标记当前数据库为已迁移(不实际执行迁移) ```bash alembic stamp head ``` ================================================ FILE: lifetrace/migrations/README ================================================ Alembic 数据库迁移目录 此目录包含 Alembic 数据库迁移脚本。 常用命令请见: [MIGRATIONS.md](MIGRATIONS.md) ================================================ FILE: lifetrace/migrations/env.py ================================================ """Alembic 迁移环境配置 配置 Alembic 使用 SQLModel 进行数据库迁移。 """ import sys from logging.config import fileConfig from pathlib import Path from alembic import context from sqlalchemy import engine_from_config, pool from sqlmodel import SQLModel # 添加项目根目录到 Python 路径 project_root = Path(__file__).parent.parent.parent sys.path.insert(0, str(project_root)) # 导入所有模型以确保 metadata 包含所有表 from lifetrace.storage.models import ( # noqa: F401, E402 Activity, ActivityEventRelation, Attachment, Chat, Event, Journal, JournalTagRelation, Message, OCRResult, Screenshot, Tag, Todo, TodoAttachmentRelation, TodoTagRelation, TokenUsage, ) from lifetrace.util.path_utils import get_database_path # noqa: E402 # Alembic Config 对象 config = context.config # 设置 Python 日志 if config.config_file_name is not None: fileConfig(config.config_file_name) # 使用 SQLModel 的 metadata target_metadata = SQLModel.metadata def get_url(): """获取数据库 URL""" return f"sqlite:///{get_database_path()}" def run_migrations_offline() -> None: """在离线模式下运行迁移。 这将配置上下文仅使用 URL,而不是 Engine, 虽然这里也可以使用 Engine。通过跳过 Engine 创建, 我们甚至不需要 DBAPI 可用。 在这里调用 context.execute() 将会将给定的字符串 发送到脚本输出。 """ url = get_url() context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, render_as_batch=True, # SQLite 需要批处理模式 ) with context.begin_transaction(): context.run_migrations() def run_migrations_online() -> None: """在在线模式下运行迁移。 在这种情况下,我们需要创建一个 Engine 并将连接与上下文关联。 """ configuration = config.get_section(config.config_ini_section) or {} configuration["sqlalchemy.url"] = get_url() connectable = engine_from_config( configuration, prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, render_as_batch=True, # SQLite 需要批处理模式来支持 ALTER TABLE ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: lifetrace/migrations/re_extract_all_transcriptions.py ================================================ #!/usr/bin/env python3 """批量重新提取转录记录的待办和日程 查找转录记录,检查每条记录是否需要重新提取: - 有 original_text 但 extracted_todos 或 extracted_schedules 为空或 "[]" - 有 optimized_text 但 extracted_todos_optimized 或 extracted_schedules_optimized 为空或 "[]" 为需要提取的记录重新提取待办和日程 支持命令行参数: --days N 只处理最近 N 天的记录 --start-date DATE 指定开始日期 (YYYY-MM-DD) --end-date DATE 指定结束日期 (YYYY-MM-DD) --ids ID1,ID2,... 指定特定的 transcription_id 列表 """ import argparse import asyncio import json import sys from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any # 添加项目根目录到路径(必须在导入之前) project_root = Path(__file__).parent.parent.parent sys.path.insert(0, str(project_root)) # 延迟导入以避免 E402 错误 if True: from sqlmodel import select from lifetrace.llm.llm_client import LLMClient from lifetrace.services.audio_extraction_service import AudioExtractionService from lifetrace.storage import get_session from lifetrace.storage.models import Transcription from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger logger = get_logger() def is_empty_extraction(extracted: str | None) -> bool: """检查提取结果是否为空""" if not extracted: return True extracted = extracted.strip() if not extracted or extracted in {"[]", "null"}: return True try: parsed = json.loads(extracted) if isinstance(parsed, list) and len(parsed) == 0: return True except (json.JSONDecodeError, TypeError): pass return False def needs_extraction(transcription: Transcription) -> tuple[bool, bool]: """检查转录记录是否需要提取 Returns: (needs_original_extraction, needs_optimized_extraction) """ needs_original = False needs_optimized = False # 检查原文提取 if ( transcription.original_text and transcription.original_text.strip() and ( is_empty_extraction(transcription.extracted_todos) or is_empty_extraction(transcription.extracted_schedules) ) ): needs_original = True # 检查优化文本提取 if ( transcription.optimized_text and transcription.optimized_text.strip() and ( is_empty_extraction(transcription.extracted_todos_optimized) or is_empty_extraction(transcription.extracted_schedules_optimized) ) ): needs_optimized = True return needs_original, needs_optimized async def re_extract_transcription( transcription: Transcription, extraction_service: AudioExtractionService, needs_original: bool, needs_optimized: bool, ) -> dict[str, Any]: """重新提取单个转录记录 Returns: 包含提取结果的字典 """ results = { "transcription_id": transcription.id, "recording_id": transcription.audio_recording_id, "original_extracted": False, "optimized_extracted": False, "errors": [], } # 提取原文 if needs_original: try: if transcription.id is None: raise ValueError("Transcription must have an id before updating.") logger.info( f"提取原文: transcription_id={transcription.id}, " f"recording_id={transcription.audio_recording_id}, " f"text_length={len(transcription.original_text or '')}" ) result = await extraction_service.extract_todos_and_schedules( transcription.original_text or "" ) extraction_service.update_extraction( transcription_id=transcription.id, todos=result.get("todos", []), schedules=result.get("schedules", []), optimized=False, ) results["original_extracted"] = True logger.info( f"✓ 原文提取完成: transcription_id={transcription.id}, " f"todos={len(result.get('todos', []))}, " f"schedules={len(result.get('schedules', []))}" ) except Exception as e: error_msg = f"原文提取失败: {e}" logger.error(f"✗ {error_msg}") results["errors"].append(error_msg) # 提取优化文本 if needs_optimized: try: if transcription.id is None: raise ValueError("Transcription must have an id before updating.") logger.info( f"提取优化文本: transcription_id={transcription.id}, " f"recording_id={transcription.audio_recording_id}, " f"text_length={len(transcription.optimized_text or '')}" ) result = await extraction_service.extract_todos_and_schedules( transcription.optimized_text or "" ) extraction_service.update_extraction( transcription_id=transcription.id, todos=result.get("todos", []), schedules=result.get("schedules", []), optimized=True, ) results["optimized_extracted"] = True logger.info( f"✓ 优化文本提取完成: transcription_id={transcription.id}, " f"todos={len(result.get('todos', []))}, " f"schedules={len(result.get('schedules', []))}" ) except Exception as e: error_msg = f"优化文本提取失败: {e}" logger.error(f"✗ {error_msg}") results["errors"].append(error_msg) return results def parse_date(date_str: str) -> datetime: """解析日期字符串 (YYYY-MM-DD)""" try: # 转换为 UTC 时间(假设输入是本地时间) return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) except ValueError as e: raise argparse.ArgumentTypeError(f"无效的日期格式: {date_str} (应为 YYYY-MM-DD)") from e def parse_ids(ids_str: str) -> list[int]: """解析 ID 列表字符串""" try: return [int(id_str.strip()) for id_str in ids_str.split(",") if id_str.strip()] except ValueError as e: raise argparse.ArgumentTypeError(f"无效的 ID 列表: {ids_str}") from e def setup_argument_parser() -> argparse.ArgumentParser: """设置命令行参数解析器""" parser = argparse.ArgumentParser( description="批量重新提取转录记录的待办和日程", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--days", type=int, help="只处理最近 N 天的记录", ) parser.add_argument( "--start-date", type=parse_date, help="指定开始日期 (YYYY-MM-DD)", ) parser.add_argument( "--end-date", type=parse_date, help="指定结束日期 (YYYY-MM-DD)", ) parser.add_argument( "--ids", type=parse_ids, help="指定特定的 transcription_id 列表,用逗号分隔,例如: --ids 1,2,3", ) return parser def calculate_date_range(args: argparse.Namespace) -> tuple[datetime | None, datetime | None]: """计算日期范围""" end_date = args.end_date start_date = args.start_date if args.days: if start_date or end_date: logger.warning("--days 参数与 --start-date/--end-date 同时指定,将使用 --days") end_date = datetime.now(UTC) start_date = end_date - timedelta(days=args.days) return start_date, end_date def log_start_info( start_date: datetime | None, end_date: datetime | None, ids: list[int] | None ) -> None: """记录开始信息""" logger.info("=" * 60) logger.info("开始批量重新提取转录记录的待办和日程") if start_date or end_date: logger.info(f"日期范围: {start_date or '无限制'} 至 {end_date or '无限制'}") if ids: logger.info(f"指定 ID 列表: {ids}") logger.info("=" * 60) def find_transcriptions_needing_extraction( start_date: datetime | None, end_date: datetime | None, ids: list[int] | None, ) -> list[tuple[int, bool, bool]]: """查找需要提取的转录记录""" needs_extraction_list = [] with get_session() as session: statement = select(Transcription) # 应用日期过滤 if start_date: statement = statement.where(Transcription.created_at >= start_date) if end_date: statement = statement.where(Transcription.created_at <= end_date) # 应用 ID 过滤 if ids: statement = statement.where(col(Transcription.id).in_(ids)) transcriptions = list(session.exec(statement).all()) logger.info(f"找到 {len(transcriptions)} 条转录记录") # 在会话内检查需要提取的记录 for transcription in transcriptions: # 在会话内访问所有属性,避免延迟加载问题 needs_original, needs_optimized = needs_extraction(transcription) if needs_original or needs_optimized: # 保存 transcription_id 而不是对象本身,避免会话分离问题 needs_extraction_list.append((transcription.id, needs_original, needs_optimized)) return needs_extraction_list async def process_extractions( needs_extraction_list: list[tuple[int, bool, bool]], extraction_service: AudioExtractionService, ) -> dict[str, int]: """处理提取任务""" stats = { "total": len(needs_extraction_list), "original_extracted": 0, "optimized_extracted": 0, "errors": 0, } # 逐个提取 for idx, (transcription_id, needs_original, needs_optimized) in enumerate( needs_extraction_list, 1 ): # 重新获取转录记录(在新的会话中) with get_session() as session: transcription = session.get(Transcription, transcription_id) if not transcription: logger.warning(f"转录记录不存在: transcription_id={transcription_id}") continue logger.info( f"\n[{idx}/{len(needs_extraction_list)}] " f"处理 transcription_id={transcription.id}, " f"recording_id={transcription.audio_recording_id}" ) result = await re_extract_transcription( transcription, extraction_service, needs_original, needs_optimized ) # 在会话内更新统计 if result["original_extracted"]: stats["original_extracted"] += 1 if result["optimized_extracted"]: stats["optimized_extracted"] += 1 if result["errors"]: stats["errors"] += 1 return stats def log_final_stats(stats: dict[str, int]) -> None: """记录最终统计信息""" logger.info("\n" + "=" * 60) logger.info("提取完成统计:") logger.info(f" 总记录数: {stats['total']}") logger.info(f" 原文提取成功: {stats['original_extracted']}") logger.info(f" 优化文本提取成功: {stats['optimized_extracted']}") logger.info(f" 错误数: {stats['errors']}") logger.info("=" * 60) async def main(): """主函数""" parser = setup_argument_parser() args = parser.parse_args() # 计算日期范围 start_date, end_date = calculate_date_range(args) # 记录开始信息 log_start_info(start_date, end_date, args.ids) # 初始化服务 llm_client = LLMClient() extraction_service = AudioExtractionService(llm_client) # 检查 LLM 客户端是否可用 if not llm_client.is_available(): logger.error("LLM 客户端不可用,无法进行提取") return # 查找需要提取的转录记录 needs_extraction_list = find_transcriptions_needing_extraction(start_date, end_date, args.ids) logger.info(f"需要重新提取的记录数: {len(needs_extraction_list)}") if len(needs_extraction_list) == 0: logger.info("所有记录都已提取完成,无需重新提取") return # 处理提取任务 stats = await process_extractions(needs_extraction_list, extraction_service) # 输出统计信息 log_final_stats(stats) if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: lifetrace/migrations/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa import sqlmodel ${imports if imports else ""} # revision identifiers, used by Alembic. revision: str = ${repr(up_revision)} down_revision: Union[str, None] = ${repr(down_revision)} branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} def upgrade() -> None: ${upgrades if upgrades else "pass"} def downgrade() -> None: ${downgrades if downgrades else "pass"} ================================================ FILE: lifetrace/migrations/versions/034079ad387f_add_segment_timestamps.py ================================================ """add_segment_timestamps Revision ID: 034079ad387f Revises: 89b2a1f0af8b Create Date: 2026-01-23 10:00:00.000000 添加 segment_timestamps 字段到 transcriptions 表,用于存储每段文本的精确时间戳 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "034079ad387f" down_revision: str = "add_file_path_001" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """添加 segment_timestamps 字段到 transcriptions 表""" connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "transcriptions" not in existing_tables: # 如果表不存在,跳过(可能在其他迁移中创建) return # 检查列是否已存在 existing_columns = [col["name"] for col in inspector.get_columns("transcriptions")] # 添加 segment_timestamps 字段(JSON 格式,存储每段文本的时间戳数组) if "segment_timestamps" not in existing_columns: op.add_column( "transcriptions", sa.Column("segment_timestamps", sa.Text(), nullable=True), ) def downgrade() -> None: """移除 segment_timestamps 字段""" connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "transcriptions" not in existing_tables: return existing_columns = [col["name"] for col in inspector.get_columns("transcriptions")] if "segment_timestamps" in existing_columns: op.drop_column("transcriptions", "segment_timestamps") ================================================ FILE: lifetrace/migrations/versions/4ca5036ec7c8_add_context_to_chats.py ================================================ """add_context_to_chats Revision ID: 4ca5036ec7c8 Revises: cc25001eb19c Create Date: 2025-12-20 14:59:34.383642 为 chats 表添加 context 字段,用于存储会话上下文(JSON 格式)。 这将会话管理从内存迁移到数据库。 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "4ca5036ec7c8" down_revision: str | None = "cc25001eb19c" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """添加 context 字段到 chats 表""" # 检查列是否已存在(防止重复添加) conn = op.get_bind() inspector = sa.inspect(conn) columns = [col["name"] for col in inspector.get_columns("chats")] if "context" not in columns: with op.batch_alter_table("chats", schema=None) as batch_op: batch_op.add_column(sa.Column("context", sa.Text(), nullable=True)) def downgrade() -> None: """移除 context 字段""" with op.batch_alter_table("chats", schema=None) as batch_op: batch_op.drop_column("context") ================================================ FILE: lifetrace/migrations/versions/add_automation_tasks_001.py ================================================ """add_automation_tasks_001 Revision ID: add_automation_tasks_001 Revises: add_todo_attachment_source_001, add_todo_reminder_offsets_001 Create Date: 2026-02-04 Create automation_tasks table for user-defined scheduled tasks. """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_automation_tasks_001" down_revision: str | Sequence[str] | None = ( "add_todo_attachment_source_001", "add_todo_reminder_offsets_001", ) branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "automation_tasks" in existing_tables: return op.create_table( "automation_tasks", sa.Column("id", sa.Integer(), primary_key=True), sa.Column("name", sa.String(length=200), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("1")), sa.Column("schedule_type", sa.String(length=20), nullable=False), sa.Column("schedule_config", sa.Text(), nullable=True), sa.Column("action_type", sa.String(length=50), nullable=False), sa.Column("action_payload", sa.Text(), nullable=True), sa.Column("last_run_at", sa.DateTime(), nullable=True), sa.Column("last_status", sa.String(length=20), nullable=True), sa.Column("last_error", sa.Text(), nullable=True), sa.Column("last_output", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(), nullable=True), sa.Column("updated_at", sa.DateTime(), nullable=True), sa.Column("deleted_at", sa.DateTime(), nullable=True), ) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "automation_tasks" in existing_tables: op.drop_table("automation_tasks") ================================================ FILE: lifetrace/migrations/versions/add_file_path_to_audio_recordings.py ================================================ """add_file_path_to_audio_recordings Revision ID: add_file_path_001 Revises: remove_project_task Create Date: 2026-01-19 06:30:00.000000 添加缺失的列到 audio_recordings 表(包括 file_path, file_size, duration 等) 如果表不存在则创建完整的表结构 同时创建 transcriptions 表(如果不存在) """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_file_path_001" down_revision: str = "remove_project_task" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """添加缺失的列到 audio_recordings 表,并创建 transcriptions 表""" connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() # 首先创建 transcriptions 表(如果不存在) _create_transcriptions_table_if_not_exists(existing_tables) # 检查 audio_recordings 表是否存在 if "audio_recordings" not in existing_tables: # 如果表不存在,创建完整的表 op.create_table( "audio_recordings", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), sa.Column("file_path", sa.String(length=500), nullable=False), sa.Column("file_size", sa.Integer(), nullable=False), sa.Column("duration", sa.Float(), nullable=False), sa.Column("start_time", sa.DateTime(), nullable=False), sa.Column("end_time", sa.DateTime(), nullable=True), sa.Column("status", sa.String(length=20), nullable=False, server_default="recording"), sa.Column("is_24x7", sa.Boolean(), nullable=False, server_default="0"), sa.Column("is_transcribed", sa.Boolean(), nullable=False, server_default="0"), sa.Column("is_extracted", sa.Boolean(), nullable=False, server_default="0"), sa.Column("is_summarized", sa.Boolean(), nullable=False, server_default="0"), sa.Column("is_full_audio", sa.Boolean(), nullable=False, server_default="0"), sa.Column("is_segment_audio", sa.Boolean(), nullable=False, server_default="0"), sa.Column( "transcription_status", sa.String(length=20), nullable=False, server_default="pending", ), ) return # 表存在,检查并添加缺失的列 columns = {col["name"]: col for col in inspector.get_columns("audio_recordings")} # 需要添加的列及其定义 columns_to_add = { "file_path": sa.Column("file_path", sa.String(length=500), nullable=True), "file_size": sa.Column("file_size", sa.Integer(), nullable=True), "duration": sa.Column("duration", sa.Float(), nullable=True), "start_time": sa.Column("start_time", sa.DateTime(), nullable=True), "end_time": sa.Column("end_time", sa.DateTime(), nullable=True), "status": sa.Column( "status", sa.String(length=20), nullable=True, server_default="recording" ), "is_24x7": sa.Column("is_24x7", sa.Boolean(), nullable=True, server_default="0"), "is_transcribed": sa.Column( "is_transcribed", sa.Boolean(), nullable=True, server_default="0" ), "is_extracted": sa.Column("is_extracted", sa.Boolean(), nullable=True, server_default="0"), "is_summarized": sa.Column( "is_summarized", sa.Boolean(), nullable=True, server_default="0" ), "is_full_audio": sa.Column( "is_full_audio", sa.Boolean(), nullable=True, server_default="0" ), "is_segment_audio": sa.Column( "is_segment_audio", sa.Boolean(), nullable=True, server_default="0" ), "transcription_status": sa.Column( "transcription_status", sa.String(length=20), nullable=True, server_default="pending" ), "created_at": sa.Column("created_at", sa.DateTime(), nullable=True), "updated_at": sa.Column("updated_at", sa.DateTime(), nullable=True), "deleted_at": sa.Column("deleted_at", sa.DateTime(), nullable=True), } # 添加缺失的列 for col_name, col_def in columns_to_add.items(): if col_name not in columns: op.add_column("audio_recordings", col_def) # 为现有记录设置默认值 op.execute("UPDATE audio_recordings SET file_path = '' WHERE file_path IS NULL") op.execute("UPDATE audio_recordings SET file_size = 0 WHERE file_size IS NULL") op.execute("UPDATE audio_recordings SET duration = 0 WHERE duration IS NULL") op.execute("UPDATE audio_recordings SET status = 'recording' WHERE status IS NULL") op.execute("UPDATE audio_recordings SET is_24x7 = 0 WHERE is_24x7 IS NULL") op.execute("UPDATE audio_recordings SET is_transcribed = 0 WHERE is_transcribed IS NULL") op.execute("UPDATE audio_recordings SET is_extracted = 0 WHERE is_extracted IS NULL") op.execute("UPDATE audio_recordings SET is_summarized = 0 WHERE is_summarized IS NULL") op.execute("UPDATE audio_recordings SET is_full_audio = 0 WHERE is_full_audio IS NULL") op.execute("UPDATE audio_recordings SET is_segment_audio = 0 WHERE is_segment_audio IS NULL") op.execute( "UPDATE audio_recordings SET transcription_status = 'pending' WHERE transcription_status IS NULL" ) def _create_transcriptions_table_if_not_exists(existing_tables: list[str]) -> None: """创建 transcriptions 表(如果不存在)""" if "transcriptions" not in existing_tables: op.create_table( "transcriptions", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), sa.Column("audio_recording_id", sa.Integer(), nullable=False), sa.Column("original_text", sa.Text(), nullable=True), sa.Column("optimized_text", sa.Text(), nullable=True), sa.Column( "extraction_status", sa.String(length=20), nullable=False, server_default="pending", ), sa.Column("extracted_todos", sa.Text(), nullable=True), sa.Column("extracted_schedules", sa.Text(), nullable=True), ) def downgrade() -> None: """移除 file_path 列""" op.drop_column("audio_recordings", "file_path") ================================================ FILE: lifetrace/migrations/versions/add_icalendar_fields_v2_001.py ================================================ """add_icalendar_fields_v2_001 Revision ID: add_icalendar_fields_v2_001 Revises: add_todo_timezone_all_day_001 Create Date: 2026-02-06 00:00:00.000000 为 todos 表补齐 iCalendar 相关字段,并回填旧字段映射。 """ from __future__ import annotations from typing import TYPE_CHECKING import sqlalchemy as sa from alembic import op if TYPE_CHECKING: from collections.abc import Sequence # revision identifiers, used by Alembic. revision: str = "add_icalendar_fields_v2_001" down_revision: str | Sequence[str] | None = "add_todo_timezone_all_day_001" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _add_missing_todo_columns(columns: set[str]) -> None: column_defs = { "item_type": sa.Column("item_type", sa.String(length=10), nullable=True), "summary": sa.Column("summary", sa.String(length=200), nullable=True), "location": sa.Column("location", sa.String(length=200), nullable=True), "categories": sa.Column("categories", sa.Text(), nullable=True), "classification": sa.Column("classification", sa.String(length=20), nullable=True), "dtstart": sa.Column("dtstart", sa.DateTime(), nullable=True), "dtend": sa.Column("dtend", sa.DateTime(), nullable=True), "due": sa.Column("due", sa.DateTime(), nullable=True), "duration": sa.Column("duration", sa.String(length=64), nullable=True), "tzid": sa.Column("tzid", sa.String(length=64), nullable=True), "dtstamp": sa.Column("dtstamp", sa.DateTime(), nullable=True), "created": sa.Column("created", sa.DateTime(), nullable=True), "last_modified": sa.Column("last_modified", sa.DateTime(), nullable=True), "sequence": sa.Column("sequence", sa.Integer(), nullable=True), "rdate": sa.Column("rdate", sa.Text(), nullable=True), "exdate": sa.Column("exdate", sa.Text(), nullable=True), "recurrence_id": sa.Column("recurrence_id", sa.DateTime(), nullable=True), "related_to_uid": sa.Column("related_to_uid", sa.String(length=64), nullable=True), "related_to_reltype": sa.Column("related_to_reltype", sa.String(length=20), nullable=True), "ical_status": sa.Column("ical_status", sa.String(length=20), nullable=True), } with op.batch_alter_table("todos", schema=None) as batch_op: for name, column_def in column_defs.items(): if name not in columns: batch_op.add_column(column_def) def _backfill_todo_ical_fields(connection: sa.Connection) -> None: updates = [ "UPDATE todos SET item_type = 'VTODO' WHERE item_type IS NULL", "UPDATE todos SET summary = name WHERE summary IS NULL AND name IS NOT NULL", "UPDATE todos SET dtstart = start_time WHERE dtstart IS NULL AND start_time IS NOT NULL", "UPDATE todos SET dtend = end_time WHERE dtend IS NULL AND end_time IS NOT NULL", "UPDATE todos SET due = deadline WHERE due IS NULL AND deadline IS NOT NULL", "UPDATE todos SET tzid = time_zone WHERE tzid IS NULL AND time_zone IS NOT NULL", "UPDATE todos SET created = created_at WHERE created IS NULL AND created_at IS NOT NULL", "UPDATE todos SET last_modified = updated_at " "WHERE last_modified IS NULL AND updated_at IS NOT NULL", "UPDATE todos SET dtstamp = updated_at WHERE dtstamp IS NULL AND updated_at IS NOT NULL", "UPDATE todos SET sequence = 0 WHERE sequence IS NULL", "UPDATE todos SET ical_status = CASE status " "WHEN 'completed' THEN 'COMPLETED' " "WHEN 'canceled' THEN 'CANCELLED' " "WHEN 'draft' THEN 'NEEDS-ACTION' " "ELSE 'NEEDS-ACTION' " "END " "WHERE ical_status IS NULL AND status IS NOT NULL", ] for stmt in updates: connection.execute(sa.text(stmt)) def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} _add_missing_todo_columns(columns) _backfill_todo_ical_fields(connection) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} drop_columns = [ "ical_status", "related_to_reltype", "related_to_uid", "recurrence_id", "exdate", "rdate", "sequence", "last_modified", "created", "dtstamp", "tzid", "duration", "due", "dtend", "dtstart", "classification", "categories", "location", "summary", "item_type", ] with op.batch_alter_table("todos", schema=None) as batch_op: for name in drop_columns: if name in columns: batch_op.drop_column(name) ================================================ FILE: lifetrace/migrations/versions/add_journal_panel_001.py ================================================ """add_journal_panel_001 Revision ID: add_journal_panel_001 Revises: merge_heads_todos_20260131 Create Date: 2026-02-03 03:05:00.000000 Extend journals table for journal panel and add relation tables. """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_journal_panel_001" down_revision: str | Sequence[str] | None = "merge_heads_todos_20260131" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _add_column_if_missing(table: str, column: sa.Column, columns: set[str]) -> None: if column.name not in columns: op.add_column(table, column) def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "journals" in existing_tables: columns = {col["name"] for col in inspector.get_columns("journals")} _add_column_if_missing( "journals", sa.Column("content_objective", sa.Text(), nullable=True), columns, ) _add_column_if_missing( "journals", sa.Column("content_ai", sa.Text(), nullable=True), columns, ) _add_column_if_missing( "journals", sa.Column("mood", sa.String(length=50), nullable=True), columns, ) _add_column_if_missing( "journals", sa.Column("energy", sa.Integer(), nullable=True), columns, ) _add_column_if_missing( "journals", sa.Column("day_bucket_start", sa.DateTime(), nullable=True), columns, ) if "journal_todo_relations" not in existing_tables: op.create_table( "journal_todo_relations", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("journal_id", sa.Integer(), nullable=False), sa.Column("todo_id", sa.Integer(), nullable=False), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), ) if "journal_activity_relations" not in existing_tables: op.create_table( "journal_activity_relations", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("journal_id", sa.Integer(), nullable=False), sa.Column("activity_id", sa.Integer(), nullable=False), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), ) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "journal_activity_relations" in existing_tables: op.drop_table("journal_activity_relations") if "journal_todo_relations" in existing_tables: op.drop_table("journal_todo_relations") if "journals" in existing_tables: columns = {col["name"] for col in inspector.get_columns("journals")} with op.batch_alter_table("journals", schema=None) as batch_op: if "day_bucket_start" in columns: batch_op.drop_column("day_bucket_start") if "energy" in columns: batch_op.drop_column("energy") if "mood" in columns: batch_op.drop_column("mood") if "content_ai" in columns: batch_op.drop_column("content_ai") if "content_objective" in columns: batch_op.drop_column("content_objective") ================================================ FILE: lifetrace/migrations/versions/add_optimized_extraction_fields.py ================================================ """add_optimized_extraction_fields Revision ID: add_optimized_extraction_001 Revises: add_file_path_001 Create Date: 2026-01-22 10:00:00.000000 添加优化文本的提取字段到 transcriptions 表 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_optimized_extraction_001" down_revision: str = "add_file_path_001" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """添加优化文本的提取字段到 transcriptions 表""" connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "transcriptions" not in existing_tables: # 如果表不存在,跳过(可能在其他迁移中创建) return # 检查列是否已存在 existing_columns = [col["name"] for col in inspector.get_columns("transcriptions")] # 添加 extracted_todos_optimized 字段 if "extracted_todos_optimized" not in existing_columns: op.add_column( "transcriptions", sa.Column("extracted_todos_optimized", sa.Text(), nullable=True), ) # 添加 extracted_schedules_optimized 字段 if "extracted_schedules_optimized" not in existing_columns: op.add_column( "transcriptions", sa.Column("extracted_schedules_optimized", sa.Text(), nullable=True), ) def downgrade() -> None: """移除优化文本的提取字段""" connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "transcriptions" not in existing_tables: return existing_columns = [col["name"] for col in inspector.get_columns("transcriptions")] if "extracted_schedules_optimized" in existing_columns: op.drop_column("transcriptions", "extracted_schedules_optimized") if "extracted_todos_optimized" in existing_columns: op.drop_column("transcriptions", "extracted_todos_optimized") ================================================ FILE: lifetrace/migrations/versions/add_text_hash_to_ocr_results.py ================================================ """add_text_hash_to_ocr_results Revision ID: add_text_hash_to_ocr_results Revises: cff6e6d7a3cf Create Date: 2026-01-23 00:00:00.000000 为 ocr_results 表添加 text_hash 列和索引,并为已有数据回填哈希值。 """ import hashlib from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_text_hash_to_ocr_results" down_revision: str | None = "cff6e6d7a3cf" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _normalize_text(text: str | None) -> str: if not text: return "" # 去掉首尾空白并压缩中间多余空白,保证哈希稳定 return " ".join(text.strip().split()) def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) columns = {col["name"] for col in inspector.get_columns("ocr_results")} # 添加 text_hash 列 if "text_hash" not in columns: with op.batch_alter_table("ocr_results", schema=None) as batch_op: batch_op.add_column(sa.Column("text_hash", sa.String(length=64), nullable=True)) # 为 text_hash 创建索引(如果不存在) indexes = {idx["name"] for idx in inspector.get_indexes("ocr_results")} index_name = "idx_ocr_results_text_hash" if index_name not in indexes: op.create_index(index_name, "ocr_results", ["text_hash"], unique=False) # 回填已有数据的 text_hash result = connection.execute(sa.text("SELECT id, text_content FROM ocr_results")) rows = result.mappings().all() for row in rows: normalized = _normalize_text(row["text_content"]) text_hash = ( None if not normalized else hashlib.md5(normalized.encode("utf-8"), usedforsecurity=False).hexdigest() ) connection.execute( sa.text("UPDATE ocr_results SET text_hash = :text_hash WHERE id = :id"), {"text_hash": text_hash, "id": row["id"]}, ) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) # 删除索引(如果存在) indexes = {idx["name"] for idx in inspector.get_indexes("ocr_results")} index_name = "idx_ocr_results_text_hash" if index_name in indexes: op.drop_index(index_name, table_name="ocr_results") # 删除 text_hash 列(如果存在) columns = {col["name"] for col in inspector.get_columns("ocr_results")} if "text_hash" in columns: with op.batch_alter_table("ocr_results", schema=None) as batch_op: batch_op.drop_column("text_hash") ================================================ FILE: lifetrace/migrations/versions/add_todo_attachment_source_001.py ================================================ """add_todo_attachment_source_001 Revision ID: add_todo_attachment_source_001 Revises: merge_heads_todos_20260131 Create Date: 2026-02-01 为 todo_attachment_relations 表添加 source 字段(user/ai)。 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_todo_attachment_source_001" down_revision: str | Sequence[str] | None = "merge_heads_todos_20260131" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todo_attachment_relations" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todo_attachment_relations")} if "source" not in columns: op.add_column( "todo_attachment_relations", sa.Column("source", sa.String(length=20), nullable=False, server_default="user"), ) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todo_attachment_relations" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todo_attachment_relations")} if "source" in columns: op.drop_column("todo_attachment_relations", "source") ================================================ FILE: lifetrace/migrations/versions/add_todo_end_time_001.py ================================================ """add_todo_end_time_001 Revision ID: add_todo_end_time_001 Revises: cff6e6d7a3cf Create Date: 2026-01-30 20:30:00.000000 为 todos 表添加 end_time 字段。 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_todo_end_time_001" down_revision: str | Sequence[str] | None = "cff6e6d7a3cf" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} if "end_time" not in columns: op.add_column("todos", sa.Column("end_time", sa.DateTime(), nullable=True)) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} if "end_time" in columns: op.drop_column("todos", "end_time") ================================================ FILE: lifetrace/migrations/versions/add_todo_reminder_offsets_001.py ================================================ """add_todo_reminder_offsets_001 Revision ID: add_todo_reminder_offsets_001 Revises: merge_heads_todos_20260131 Create Date: 2026-02-01 Add reminder_offsets column to todos table. """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_todo_reminder_offsets_001" down_revision: str | Sequence[str] | None = "merge_heads_todos_20260131" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} if "reminder_offsets" not in columns: op.add_column("todos", sa.Column("reminder_offsets", sa.Text(), nullable=True)) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} if "reminder_offsets" in columns: op.drop_column("todos", "reminder_offsets") ================================================ FILE: lifetrace/migrations/versions/add_todo_timezone_all_day_001.py ================================================ """add_todo_timezone_all_day_001 Revision ID: add_todo_timezone_all_day_001 Revises: merge_heads_todos_20260131 Create Date: 2026-02-03 05:30:00.000000 为 todos 表添加 time_zone 与 is_all_day 字段。 """ from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "add_todo_timezone_all_day_001" down_revision: str | Sequence[str] | None = "merge_heads_todos_20260131" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} if "time_zone" not in columns: op.add_column("todos", sa.Column("time_zone", sa.String(length=64), nullable=True)) if "is_all_day" not in columns: op.add_column( "todos", sa.Column("is_all_day", sa.Boolean(), nullable=True, server_default=sa.text("0")), ) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() if "todos" not in existing_tables: return columns = {col["name"] for col in inspector.get_columns("todos")} if "is_all_day" in columns: op.drop_column("todos", "is_all_day") if "time_zone" in columns: op.drop_column("todos", "time_zone") ================================================ FILE: lifetrace/migrations/versions/b53d9b7c8e21_add_uid_to_journals.py ================================================ """add_uid_to_journals Revision ID: b53d9b7c8e21 Revises: remove_project_task Create Date: 2026-02-03 12:00:00.000000 为 journals 表添加 iCalendar UID 字段并回填已有数据。 """ from __future__ import annotations from typing import TYPE_CHECKING from uuid import uuid4 import sqlalchemy as sa from alembic import op if TYPE_CHECKING: from collections.abc import Sequence # revision identifiers, used by Alembic. revision: str = "b53d9b7c8e21" down_revision: str | None = "merge_heads_journal_todo_20260203" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _add_missing_journal_columns(columns: set[str]) -> None: with op.batch_alter_table("journals", schema=None) as batch_op: if "uid" not in columns: batch_op.add_column(sa.Column("uid", sa.String(length=64), nullable=True)) def _ensure_journal_uid_index(inspector: sa.Inspector) -> None: indexes = {idx["name"] for idx in inspector.get_indexes("journals")} if "idx_journals_uid" not in indexes: op.create_index("idx_journals_uid", "journals", ["uid"], unique=False) def _backfill_journal_uids(connection: sa.Connection) -> None: result = connection.execute(sa.text("SELECT id, uid FROM journals")) rows = result.mappings().all() for row in rows: uid = row.get("uid") if uid: continue connection.execute( sa.text("UPDATE journals SET uid = :uid WHERE id = :id"), # nosec B608 {"uid": str(uuid4()), "id": row["id"]}, ) def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) if "journals" not in inspector.get_table_names(): return columns = {col["name"] for col in inspector.get_columns("journals")} _add_missing_journal_columns(columns) _ensure_journal_uid_index(inspector) _backfill_journal_uids(connection) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) if "journals" not in inspector.get_table_names(): return indexes = {idx["name"] for idx in inspector.get_indexes("journals")} if "idx_journals_uid" in indexes: op.drop_index("idx_journals_uid", table_name="journals") columns = {col["name"] for col in inspector.get_columns("journals")} with op.batch_alter_table("journals", schema=None) as batch_op: if "uid" in columns: batch_op.drop_column("uid") ================================================ FILE: lifetrace/migrations/versions/cc25001eb19c_initial_schema.py ================================================ """initial_schema Revision ID: cc25001eb19c Revises: Create Date: 2025-12-20 14:58:03.694426 这是一个基线迁移,用于标记现有数据库结构。 对于已存在的数据库,此迁移不执行任何操作。 """ from collections.abc import Sequence # revision identifiers, used by Alembic. revision: str = "cc25001eb19c" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """基线迁移 - 不执行任何操作 现有数据库的表结构已经正确,此迁移仅用于建立 Alembic 版本基线。 新数据库的表结构由 SQLModel.metadata.create_all() 创建。 """ pass def downgrade() -> None: """基线迁移 - 不执行任何操作""" pass ================================================ FILE: lifetrace/migrations/versions/cff6e6d7a3cf_merge_heads_segment_timestamps_and_.py ================================================ """merge heads: segment_timestamps and optimized_extraction Revision ID: cff6e6d7a3cf Revises: 034079ad387f, add_optimized_extraction_001 Create Date: 2026-01-23 20:34:00.629399 """ from collections.abc import Sequence # revision identifiers, used by Alembic. revision: str = "cff6e6d7a3cf" down_revision: str | None = ("034079ad387f", "add_optimized_extraction_001") branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: pass def downgrade() -> None: pass ================================================ FILE: lifetrace/migrations/versions/d2f7a9c6b1a4_add_icalendar_fields_to_todos.py ================================================ """add_icalendar_fields_to_todos Revision ID: d2f7a9c6b1a4 Revises: add_text_hash_to_ocr_results Create Date: 2026-01-29 23:30:00.000000 为 todos 表添加 iCalendar 相关字段,并回填已有数据。 """ from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING from uuid import uuid4 import sqlalchemy as sa from alembic import op if TYPE_CHECKING: from collections.abc import Sequence # revision identifiers, used by Alembic. revision: str = "d2f7a9c6b1a4" down_revision: str | None = "add_text_hash_to_ocr_results" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def _add_missing_todo_columns(columns: set[str]) -> None: with op.batch_alter_table("todos", schema=None) as batch_op: column_defs = { "uid": sa.Column("uid", sa.String(length=64), nullable=True), "completed_at": sa.Column("completed_at", sa.DateTime(), nullable=True), "percent_complete": sa.Column("percent_complete", sa.Integer(), nullable=True), "rrule": sa.Column("rrule", sa.String(length=500), nullable=True), } for name, column_def in column_defs.items(): if name not in columns: batch_op.add_column(column_def) def _ensure_todo_uid_index(inspector: sa.Inspector) -> None: indexes = {idx["name"] for idx in inspector.get_indexes("todos")} if "idx_todos_uid" not in indexes: op.create_index("idx_todos_uid", "todos", ["uid"], unique=False) def _build_todo_updates(row: dict[str, object]) -> dict[str, object]: updates: dict[str, object] = {} uid = row.get("uid") if not uid: updates["uid"] = str(uuid4()) percent_complete = row.get("percent_complete") if percent_complete is None: updates["percent_complete"] = 100 if row.get("status") == "completed" else 0 if row.get("status") == "completed" and row.get("completed_at") is None: fallback = row.get("updated_at") or row.get("created_at") if isinstance(fallback, datetime): updates["completed_at"] = fallback return updates def _backfill_todo_ical_fields(connection: sa.Connection) -> None: result = connection.execute( sa.text( "SELECT id, uid, status, completed_at, percent_complete, updated_at, created_at FROM todos" ) ) rows = result.mappings().all() for row in rows: updates = _build_todo_updates(dict(row)) if not updates: continue updates["id"] = row["id"] sets = ", ".join([f"{key} = :{key}" for key in updates if key != "id"]) connection.execute( sa.text(f"UPDATE todos SET {sets} WHERE id = :id"), # nosec B608 updates, ) def upgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) columns = {col["name"] for col in inspector.get_columns("todos")} _add_missing_todo_columns(columns) _ensure_todo_uid_index(inspector) _backfill_todo_ical_fields(connection) def downgrade() -> None: connection = op.get_bind() inspector = sa.inspect(connection) indexes = {idx["name"] for idx in inspector.get_indexes("todos")} if "idx_todos_uid" in indexes: op.drop_index("idx_todos_uid", table_name="todos") columns = {col["name"] for col in inspector.get_columns("todos")} with op.batch_alter_table("todos", schema=None) as batch_op: if "rrule" in columns: batch_op.drop_column("rrule") if "percent_complete" in columns: batch_op.drop_column("percent_complete") if "completed_at" in columns: batch_op.drop_column("completed_at") if "uid" in columns: batch_op.drop_column("uid") ================================================ FILE: lifetrace/migrations/versions/merge_automation_ical_001.py ================================================ """merge_automation_ical_001 Revision ID: merge_automation_ical_001 Revises: merge_journal_uid_automation_20260204, add_icalendar_fields_v2_001 Create Date: 2026-02-06 Merge heads for automation tasks and iCalendar fields. """ from collections.abc import Sequence from alembic import op # revision identifiers, used by Alembic. revision: str = "merge_automation_ical_001" down_revision: str | Sequence[str] | None = ( "merge_journal_uid_automation_20260204", "add_icalendar_fields_v2_001", ) branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Merge heads - no schema changes.""" op.execute("SELECT 1") def downgrade() -> None: """Merge heads - no schema changes.""" op.execute("SELECT 1") ================================================ FILE: lifetrace/migrations/versions/merge_heads_journal_todo_20260203.py ================================================ """merge_heads_journal_todo_20260203 Revision ID: merge_heads_journal_todo_20260203 Revises: add_journal_panel_001, add_todo_attachment_source_001 Create Date: 2026-02-03 """ from collections.abc import Sequence from alembic import op # revision identifiers, used by Alembic. revision: str = "merge_heads_journal_todo_20260203" down_revision: str | Sequence[str] | None = ( "add_journal_panel_001", "add_todo_attachment_source_001", ) branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.execute("SELECT 1") def downgrade() -> None: op.execute("SELECT 1") ================================================ FILE: lifetrace/migrations/versions/merge_heads_todos_20260131.py ================================================ """merge heads: todos end_time and icalendar fields Revision ID: merge_heads_todos_20260131 Revises: d2f7a9c6b1a4, add_todo_end_time_001 Create Date: 2026-01-31 合并两个并行 head: - d2f7a9c6b1a4(todos 的 iCalendar 字段) - add_todo_end_time_001(todos 的 end_time 字段) 该迁移仅用于合并 revision 图,不包含 schema 变更。 """ from collections.abc import Sequence # revision identifiers, used by Alembic. revision: str = "merge_heads_todos_20260131" down_revision: str | Sequence[str] | None = ("d2f7a9c6b1a4", "add_todo_end_time_001") branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: pass def downgrade() -> None: pass ================================================ FILE: lifetrace/migrations/versions/merge_journal_uid_automation_20260204.py ================================================ """merge_journal_uid_automation_20260204 Revision ID: merge_journal_uid_automation_20260204 Revises: b53d9b7c8e21, add_automation_tasks_001 Create Date: 2026-02-04 Merge heads for journal UID and automation tasks. """ from collections.abc import Sequence from alembic import op # revision identifiers, used by Alembic. revision: str = "merge_journal_uid_automation_20260204" down_revision: str | Sequence[str] | None = ( "b53d9b7c8e21", "add_automation_tasks_001", ) branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Merge heads - no schema changes.""" op.execute("SELECT 1") def downgrade() -> None: """Merge heads - no schema changes.""" op.execute("SELECT 1") ================================================ FILE: lifetrace/migrations/versions/remove_project_task_tables.py ================================================ """Remove project and task related tables 删除项目管理相关的表和字段: - 删除 projects 表 - 删除 tasks 表 - 删除 task_progress 表 - 删除 event_task_relations 表 - 从 events 表中删除 task_id 和 auto_association_attempted 字段 Revision ID: remove_project_task Revises: 4ca5036ec7c8 Create Date: 2025-01-07 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision = "remove_project_task" down_revision = "4ca5036ec7c8" branch_labels = None depends_on = None def upgrade() -> None: """删除项目和任务相关的表和字段""" connection = op.get_bind() inspector = sa.inspect(connection) existing_tables = inspector.get_table_names() # 1. 删除 event_task_relations 表(如果存在) if "event_task_relations" in existing_tables: op.drop_table("event_task_relations") # 2. 删除 task_progress 表(如果存在) if "task_progress" in existing_tables: op.drop_table("task_progress") # 3. 删除 tasks 表(如果存在) if "tasks" in existing_tables: op.drop_table("tasks") # 4. 删除 projects 表(如果存在) if "projects" in existing_tables: op.drop_table("projects") # 5. 从 events 表中删除 task_id 和 auto_association_attempted 字段(如果存在) if "events" in existing_tables: columns = {col["name"] for col in inspector.get_columns("events")} columns_to_drop = [] if "task_id" in columns: columns_to_drop.append("task_id") if "auto_association_attempted" in columns: columns_to_drop.append("auto_association_attempted") if columns_to_drop: with op.batch_alter_table("events") as batch_op: for col in columns_to_drop: batch_op.drop_column(col) def downgrade() -> None: """恢复项目和任务相关的表和字段(回滚用)""" # 1. 恢复 events 表中的字段 with op.batch_alter_table("events") as batch_op: batch_op.add_column(sa.Column("task_id", sa.Integer(), nullable=True)) batch_op.add_column( sa.Column( "auto_association_attempted", sa.Boolean(), nullable=False, server_default="0" ) ) # 2. 恢复 projects 表 op.create_table( "projects", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("name", sa.String(length=200), nullable=False), sa.Column("definition_of_done", sa.Text(), nullable=True), sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), sa.Column("description", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), ) # 3. 恢复 tasks 表 op.create_table( "tasks", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("project_id", sa.Integer(), nullable=False), sa.Column("name", sa.String(length=200), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), ) # 4. 恢复 task_progress 表 op.create_table( "task_progress", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("task_id", sa.Integer(), nullable=False), sa.Column("summary", sa.Text(), nullable=False), sa.Column("context_count", sa.Integer(), nullable=False, server_default="0"), sa.Column("generated_at", sa.DateTime(), nullable=False), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), ) # 5. 恢复 event_task_relations 表 op.create_table( "event_task_relations", sa.Column("id", sa.Integer(), nullable=False, primary_key=True), sa.Column("event_id", sa.Integer(), nullable=False), sa.Column("project_id", sa.Integer(), nullable=True), sa.Column("task_id", sa.Integer(), nullable=True), sa.Column("project_confidence", sa.Float(), nullable=True), sa.Column("task_confidence", sa.Float(), nullable=True), sa.Column("reasoning", sa.Text(), nullable=True), sa.Column("association_method", sa.String(length=50), nullable=True), sa.Column("used_in_summary", sa.Boolean(), nullable=False, server_default="0"), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), ) ================================================ FILE: lifetrace/models/ch_PP-OCRv4_rec_infer.onnx ================================================ [File too large to display: 10.4 MB] ================================================ FILE: lifetrace/observability/__init__.py ================================================ """Observability 模块 - Phoenix + OpenInference 集成 提供 Agent 运行的可观测性功能: - 本地 JSON 文件导出(Cursor 友好) - Phoenix UI 集成(可选) - Terminal 精简摘要输出 """ from lifetrace.observability.setup import setup_observability __all__ = ["setup_observability"] ================================================ FILE: lifetrace/observability/config.py ================================================ """Observability 配置类 定义观测功能的配置结构和默认值。 """ from dataclasses import dataclass, field from pathlib import Path from typing import Literal from lifetrace.util.base_paths import get_user_data_dir from lifetrace.util.settings import settings @dataclass class LocalExporterConfig: """本地文件导出器配置""" traces_dir: str = "traces/" max_files: int = 100 pretty_print: bool = True @dataclass class PhoenixConfig: """Phoenix 配置""" endpoint: str = "http://localhost:6006" project_name: str = "freetodo-agent" export_timeout_sec: float = 2.0 disable_after_failures: int = 1 retry_cooldown_sec: float = 60.0 @dataclass class TerminalConfig: """Terminal 输出配置""" summary_only: bool = True @dataclass class ObservabilityConfig: """观测系统配置""" enabled: bool = False mode: Literal["local", "phoenix", "both"] = "both" local: LocalExporterConfig = field(default_factory=LocalExporterConfig) phoenix: PhoenixConfig = field(default_factory=PhoenixConfig) terminal: TerminalConfig = field(default_factory=TerminalConfig) def get_observability_config() -> ObservabilityConfig: """从 settings 获取观测配置 Returns: ObservabilityConfig: 观测配置对象 """ obs_settings = settings.get("observability", {}) # 如果配置不存在或为空,返回默认配置(禁用状态) if not obs_settings: return ObservabilityConfig() # 解析 local 配置 local_settings = obs_settings.get("local", {}) local_config = LocalExporterConfig( traces_dir=local_settings.get("traces_dir", "traces/"), max_files=local_settings.get("max_files", 100), pretty_print=local_settings.get("pretty_print", True), ) # 解析 phoenix 配置 phoenix_settings = obs_settings.get("phoenix", {}) phoenix_config = PhoenixConfig( endpoint=phoenix_settings.get("endpoint", "http://localhost:6006"), project_name=phoenix_settings.get("project_name", "freetodo-agent"), export_timeout_sec=phoenix_settings.get("export_timeout_sec", 2.0), disable_after_failures=phoenix_settings.get("disable_after_failures", 1), retry_cooldown_sec=phoenix_settings.get("retry_cooldown_sec", 60.0), ) # 解析 terminal 配置 terminal_settings = obs_settings.get("terminal", {}) terminal_config = TerminalConfig( summary_only=terminal_settings.get("summary_only", True), ) return ObservabilityConfig( enabled=obs_settings.get("enabled", False), mode=obs_settings.get("mode", "both"), local=local_config, phoenix=phoenix_config, terminal=terminal_config, ) def get_traces_directory() -> Path: """获取 traces 目录的完整路径 Returns: Path: traces 目录路径 """ config = get_observability_config() data_dir = get_user_data_dir() traces_dir = data_dir / config.local.traces_dir traces_dir.mkdir(parents=True, exist_ok=True) return traces_dir ================================================ FILE: lifetrace/observability/exporters/__init__.py ================================================ """Observability Exporters 模块 提供各种 trace 导出器实现。 """ from lifetrace.observability.exporters.file_exporter import LocalFileExporter from lifetrace.observability.exporters.phoenix_exporter import PhoenixCircuitBreakerExporter __all__ = ["LocalFileExporter", "PhoenixCircuitBreakerExporter"] ================================================ FILE: lifetrace/observability/exporters/file_exporter.py ================================================ """本地文件导出器 将 OpenTelemetry spans 转换为可读的 JSON 格式并写入本地文件。 设计目标: - Cursor 友好:结构化 JSON,便于 AI 分析 - 人类可读:格式化输出,清晰的字段命名 - 日志精简:Terminal 只输出一行摘要 - 按会话聚合:同一 session 的所有 trace 保存在同一个文件中 """ from __future__ import annotations import contextlib import importlib import json import os import threading from collections import defaultdict from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from lifetrace.util.base_paths import get_user_data_dir from lifetrace.util.logging_config import get_logger if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from opentelemetry.sdk.trace import ReadableSpan logger = get_logger() def _get_current_session_id() -> str | None: """获取当前 session_id(从 ContextVar 读取)""" try: agno_module = importlib.import_module("lifetrace.llm.agno_agent") return agno_module.current_session_id.get() except Exception: return None # OpenInference 语义约定中的常用属性 OPENINFERENCE_SPAN_KIND = "openinference.span.kind" OPENINFERENCE_INPUT_VALUE = "input.value" OPENINFERENCE_OUTPUT_VALUE = "output.value" OPENINFERENCE_LLM_MODEL_NAME = "llm.model_name" OPENINFERENCE_LLM_INPUT_MESSAGES = "llm.input_messages" OPENINFERENCE_LLM_OUTPUT_MESSAGES = "llm.output_messages" OPENINFERENCE_LLM_TOKEN_COUNT_PROMPT = "llm.token_count.prompt" # nosec B105 OPENINFERENCE_LLM_TOKEN_COUNT_COMPLETION = "llm.token_count.completion" # nosec B105 OPENINFERENCE_TOOL_NAME = "tool.name" OPENINFERENCE_TOOL_PARAMETERS = "tool.parameters" def _coerce_int(value: Any) -> int: try: return int(value) except (TypeError, ValueError): return 0 class LocalFileExporter(SpanExporter): """本地 JSON 文件导出器 将 traces 写入本地 JSON 文件,支持: - 按 session_id 聚合:同一会话的所有 trace 保存在同一个文件中 - 按 trace_id 聚合 spans(当无 session_id 时) - 格式化输出便于阅读 - Terminal 摘要输出 - 自动清理旧文件 """ def __init__( self, traces_dir: str = "traces/", max_files: int = 100, pretty_print: bool = True, summary_only: bool = True, ): """初始化导出器 Args: traces_dir: trace 文件存储目录(相对于 base_dir) max_files: 最大保留文件数 pretty_print: 是否格式化 JSON 输出 summary_only: Terminal 是否只输出摘要 """ self.traces_dir = traces_dir self.max_files = max_files self.pretty_print = pretty_print self.summary_only = summary_only self._lock = threading.Lock() # 用于聚合同一 trace 的 spans self._pending_traces: dict[str, list[ReadableSpan]] = defaultdict(list) # session_id -> 文件路径的映射(内存缓存) self._session_files: dict[str, Path] = {} def _get_traces_path(self) -> Path: """获取 traces 目录路径""" traces_path = get_user_data_dir() / self.traces_dir traces_path.mkdir(parents=True, exist_ok=True) return traces_path def _get_session_file_path(self, session_id: str) -> Path: """获取 session 文件路径 如果已有该 session 的文件,返回现有路径;否则创建新路径。 文件名格式:session_{session_id}_{创建时间}.json """ # 检查内存缓存 if session_id in self._session_files: return self._session_files[session_id] traces_path = self._get_traces_path() # 查找已有的 session 文件 existing_files = list(traces_path.glob(f"session_{session_id}_*.json")) if existing_files: # 使用最新的文件 filepath = max(existing_files, key=lambda f: f.stat().st_mtime) self._session_files[session_id] = filepath return filepath # 创建新文件路径 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") filename = f"session_{session_id}_{timestamp}.json" filepath = traces_path / filename self._session_files[session_id] = filepath return filepath def _load_session_data(self, filepath: Path) -> dict[str, Any]: """加载已有的 session 数据""" if not filepath.exists(): return {} try: with open(filepath, encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: logger.warning(f"加载 session 文件失败: {e}") return {} def _save_session_data(self, filepath: Path, data: dict[str, Any]) -> bool: """保存 session 数据""" try: with open(filepath, "w", encoding="utf-8") as f: if self.pretty_print: json.dump(data, f, ensure_ascii=False, indent=2) else: json.dump(data, f, ensure_ascii=False) return True except OSError as e: logger.error(f"保存 session 文件失败: {e}") return False def _extract_span_kind(self, span: ReadableSpan) -> str: """提取 span 类型""" attrs = dict(span.attributes or {}) return str(attrs.get(OPENINFERENCE_SPAN_KIND, span.name)) def _extract_tool_call(self, span: ReadableSpan) -> dict[str, Any] | None: """从 span 提取工具调用信息""" attrs = dict(span.attributes or {}) span_kind = attrs.get(OPENINFERENCE_SPAN_KIND, "") if span_kind != "TOOL" and "tool" not in span.name.lower(): return None tool_name = attrs.get(OPENINFERENCE_TOOL_NAME, span.name) tool_params = attrs.get(OPENINFERENCE_TOOL_PARAMETERS, "{}") # 尝试解析参数 try: args = json.loads(tool_params) if isinstance(tool_params, str) else tool_params except (json.JSONDecodeError, TypeError): args = {"raw": str(tool_params)} # 获取结果 output = attrs.get(OPENINFERENCE_OUTPUT_VALUE, "") result_preview = str(output)[:200] if output else "" # 计算持续时间 duration_ms = 0 if span.start_time and span.end_time: duration_ms = (span.end_time - span.start_time) / 1_000_000 # ns -> ms return { "name": str(tool_name), "args": args, "result_preview": result_preview, "duration_ms": round(duration_ms, 2), } def _extract_llm_call(self, span: ReadableSpan) -> dict[str, Any] | None: """从 span 提取 LLM 调用信息""" attrs = dict(span.attributes or {}) span_kind = attrs.get(OPENINFERENCE_SPAN_KIND, "") if span_kind != "LLM" and "llm" not in span.name.lower(): return None model = attrs.get(OPENINFERENCE_LLM_MODEL_NAME, "unknown") input_tokens = _coerce_int(attrs.get(OPENINFERENCE_LLM_TOKEN_COUNT_PROMPT, 0)) output_tokens = _coerce_int(attrs.get(OPENINFERENCE_LLM_TOKEN_COUNT_COMPLETION, 0)) # 计算持续时间 duration_ms = 0 if span.start_time and span.end_time: duration_ms = (span.end_time - span.start_time) / 1_000_000 return { "model": str(model), "input_tokens": input_tokens, "output_tokens": output_tokens, "duration_ms": round(duration_ms, 2), } def _aggregate_spans(self, spans: Sequence[ReadableSpan]) -> dict[str, Any]: """将 spans 聚合为结构化的 trace 数据 Args: spans: OpenTelemetry spans 列表 Returns: 聚合后的 trace 数据字典 """ if not spans: return {} # 获取基本信息 first_span = spans[0] trace_id = format(first_span.context.trace_id, "032x") if first_span.context else "unknown" # 找到根 span(通常是 agent 运行) root_span = None for span in spans: if span.parent is None: root_span = span break if root_span is None: root_span = first_span # 提取输入输出 root_attrs = dict(root_span.attributes or {}) input_value = root_attrs.get(OPENINFERENCE_INPUT_VALUE, "") output_value = root_attrs.get(OPENINFERENCE_OUTPUT_VALUE, "") # 计算总持续时间 start_time = min(s.start_time for s in spans if s.start_time) end_time = max(s.end_time for s in spans if s.end_time) total_duration_ms = (end_time - start_time) / 1_000_000 if start_time and end_time else 0 # 提取工具调用和 LLM 调用 tool_calls = [] llm_calls = [] for span in spans: tool_call = self._extract_tool_call(span) if tool_call: tool_calls.append(tool_call) llm_call = self._extract_llm_call(span) if llm_call: llm_calls.append(llm_call) # 确定状态 status = "success" for span in spans: if span.status and span.status.is_ok is False: status = "error" break # 生成时间戳 timestamp = datetime.now(UTC).isoformat() return { "trace_id": trace_id[:12], # 使用短 ID "timestamp": timestamp, "duration_ms": round(total_duration_ms, 2), "agent": root_span.name, "input": str(input_value)[:500] if input_value else "", "output_preview": str(output_value)[:500] if output_value else "", "tool_calls": tool_calls, "llm_calls": llm_calls, "status": status, "span_count": len(spans), } def _write_to_file( self, trace_data: dict[str, Any], session_id: str | None = None ) -> str | None: """将 trace 数据写入 JSON 文件 Args: trace_data: 聚合后的 trace 数据 session_id: 会话 ID,如果提供则追加到 session 文件 Returns: 写入的文件路径,失败返回 None """ if not trace_data: return None # 如果有 session_id,追加到 session 文件 if session_id: return self._write_to_session_file(trace_data, session_id) # 否则,每个 trace 一个文件(原有行为) traces_path = self._get_traces_path() trace_id = trace_data.get("trace_id", "unknown") timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") filename = f"{timestamp}_{trace_id}.json" filepath = traces_path / filename try: with open(filepath, "w", encoding="utf-8") as f: if self.pretty_print: json.dump(trace_data, f, ensure_ascii=False, indent=2) else: json.dump(trace_data, f, ensure_ascii=False) # 清理旧文件 self._cleanup_old_files(traces_path) return str(filepath) except Exception as e: logger.error(f"写入 trace 文件失败: {e}") return None def _write_to_session_file(self, trace_data: dict[str, Any], session_id: str) -> str | None: """将 trace 数据追加到 session 文件 Session 文件格式: { "session_id": "xxx", "created_at": "2026-01-23T08:27:03Z", "updated_at": "2026-01-23T08:30:00Z", "traces": [...], "summary": {...} } """ filepath = self._get_session_file_path(session_id) try: # 加载已有数据或创建新结构 session_data = self._load_session_data(filepath) if not session_data: session_data = { "session_id": session_id, "created_at": datetime.now(UTC).isoformat(), "traces": [], "summary": { "total_duration_ms": 0, "tool_count": 0, "llm_count": 0, "trace_count": 0, "status": "success", }, } # 追加 trace session_data["traces"].append(trace_data) session_data["updated_at"] = datetime.now(UTC).isoformat() # 更新摘要 summary = session_data["summary"] summary["total_duration_ms"] += trace_data.get("duration_ms", 0) summary["tool_count"] += len(trace_data.get("tool_calls", [])) summary["llm_count"] += len(trace_data.get("llm_calls", [])) summary["trace_count"] = len(session_data["traces"]) if trace_data.get("status") == "error": summary["status"] = "error" # 保存 if self._save_session_data(filepath, session_data): # 清理旧文件 self._cleanup_old_files(self._get_traces_path()) return str(filepath) return None except Exception as e: logger.error(f"写入 session 文件失败: {e}") return None def _cleanup_old_files(self, traces_path: Path) -> None: """清理超出限制的旧文件 Args: traces_path: traces 目录路径 """ try: json_files = sorted( traces_path.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True, ) if len(json_files) > self.max_files: for old_file in json_files[self.max_files :]: with contextlib.suppress(OSError): old_file.unlink() except Exception as e: logger.debug(f"清理旧文件失败: {e}") def _print_summary( self, trace_data: dict[str, Any], filepath: str | None, session_id: str | None = None, ) -> None: """输出 Terminal 摘要 Args: trace_data: trace 数据 filepath: 文件路径 session_id: 会话 ID """ if not trace_data: return trace_id = trace_data.get("trace_id", "unknown") tool_count = len(trace_data.get("tool_calls", [])) llm_count = len(trace_data.get("llm_calls", [])) duration_ms = trace_data.get("duration_ms", 0) duration_s = duration_ms / 1000 # 获取相对路径用于显示 if filepath: try: rel_path = os.path.relpath(filepath, get_user_data_dir()) except Exception: rel_path = filepath else: rel_path = "N/A" # 构建摘要信息 parts = [f"[Trace] {trace_id}"] if session_id: parts.append(f"session:{session_id[:8]}") parts.append(f"{tool_count} tools") if llm_count > 0: parts.append(f"{llm_count} llm") parts.append(f"{duration_s:.2f}s") parts.append(rel_path) if self.summary_only: # 精简输出:一行摘要 logger.info(" | ".join(parts)) else: # 详细输出 logger.info(f"[Trace] {trace_id}") if session_id: logger.info(f" Session: {session_id}") logger.info(f" Duration: {duration_s:.2f}s") logger.info(f" Tools: {tool_count}") logger.info(f" LLM calls: {llm_count}") logger.info(f" File: {rel_path}") for tool in trace_data.get("tool_calls", []): logger.info(f" - {tool['name']}: {tool.get('duration_ms', 0):.0f}ms") for llm in trace_data.get("llm_calls", []): logger.info( f" - LLM({llm.get('model', 'unknown')}): {llm.get('duration_ms', 0):.0f}ms" ) def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: """导出 spans Args: spans: 要导出的 spans Returns: 导出结果 """ if not spans: return SpanExportResult.SUCCESS # 获取当前 session_id(在处理 spans 时获取,因为 ContextVar 可能在之后被重置) session_id = _get_current_session_id() with self._lock: try: # 按 trace_id 分组 traces: dict[str, list[ReadableSpan]] = defaultdict(list) for span in spans: if span.context: trace_id = format(span.context.trace_id, "032x") traces[trace_id].append(span) # 处理每个 trace for trace_id, trace_spans in traces.items(): # 检查是否有根 span(表示 trace 完成) has_root = any(s.parent is None for s in trace_spans) if has_root: # 合并之前缓存的 spans all_spans = self._pending_traces.pop(trace_id, []) + trace_spans # 聚合并写入 trace_data = self._aggregate_spans(all_spans) if trace_data: filepath = self._write_to_file(trace_data, session_id) self._print_summary(trace_data, filepath, session_id) else: # 缓存非根 spans,等待完整 trace self._pending_traces[trace_id].extend(trace_spans) return SpanExportResult.SUCCESS except Exception as e: logger.error(f"导出 spans 失败: {e}") return SpanExportResult.FAILURE def shutdown(self) -> None: """关闭导出器,处理剩余的 spans""" with self._lock: # 导出所有缓存的 traces(shutdown 时无法获取 session_id,使用独立文件) for _trace_id, spans in self._pending_traces.items(): if spans: trace_data = self._aggregate_spans(spans) if trace_data: filepath = self._write_to_file(trace_data, session_id=None) self._print_summary(trace_data, filepath, session_id=None) self._pending_traces.clear() self._session_files.clear() def force_flush(self, timeout_millis: int = 30000) -> bool: """强制刷新 Args: timeout_millis: 超时时间(毫秒) Returns: 是否成功 """ # 对于文件导出器,export 已经是同步的,不需要特殊处理 _ = timeout_millis return True ================================================ FILE: lifetrace/observability/exporters/phoenix_exporter.py ================================================ """Phoenix 导出器包装器 为 OTLPSpanExporter 增加失败抑制与自动恢复逻辑,避免 Phoenix 未启动时刷屏报错。 """ from __future__ import annotations import socket import threading import time from typing import TYPE_CHECKING from urllib.parse import urlparse from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from lifetrace.util.logging_config import get_logger if TYPE_CHECKING: from collections.abc import Sequence from opentelemetry.sdk.trace import ReadableSpan logger = get_logger() class PhoenixCircuitBreakerExporter(SpanExporter): """Phoenix 导出器包装器(熔断 + 自动恢复)""" def __init__( self, exporter: SpanExporter, endpoint: str, disable_after_failures: int = 1, retry_cooldown_sec: float = 60.0, ) -> None: self._exporter = exporter self._endpoint = endpoint self._disable_after_failures = int(disable_after_failures) self._retry_cooldown_sec = float(retry_cooldown_sec) self._lock = threading.Lock() self._consecutive_failures = 0 self._disabled_until = 0.0 self._disabled = False self._has_logged_first_failure = False def _should_skip_export(self) -> bool: now = time.monotonic() with self._lock: if not self._disabled: return False # 禁用且不重试 if self._retry_cooldown_sec <= 0: return True # Phoenix 已启动时立即恢复(不等冷却期) if self._endpoint_is_reachable(): self._disabled = False self._consecutive_failures = 0 self._has_logged_first_failure = False self._disabled_until = 0.0 logger.info(f"Observability: Phoenix 导出已恢复 -> {self._endpoint}") return False # 仍在冷却期 if now < self._disabled_until: return True # 冷却期结束,尝试恢复 self._disabled = False self._consecutive_failures = 0 self._has_logged_first_failure = False logger.info(f"Observability: Phoenix 导出尝试恢复 -> {self._endpoint}") return False def _handle_success(self) -> None: with self._lock: if self._consecutive_failures > 0 or self._disabled: logger.info(f"Observability: Phoenix 导出已恢复 -> {self._endpoint}") self._consecutive_failures = 0 self._disabled = False self._disabled_until = 0.0 self._has_logged_first_failure = False def _handle_failure(self, error: Exception | None) -> None: with self._lock: self._consecutive_failures += 1 if not self._has_logged_first_failure: msg = f"Observability: Phoenix 导出失败 -> {self._endpoint}" if error is not None: msg = f"{msg} ({error})" logger.warning(msg) self._has_logged_first_failure = True if self._disable_after_failures <= 0: return if self._consecutive_failures >= self._disable_after_failures and not self._disabled: self._disabled = True if self._retry_cooldown_sec > 0: self._disabled_until = time.monotonic() + self._retry_cooldown_sec logger.warning( "Observability: Phoenix 导出已暂停," f"{self._retry_cooldown_sec:.0f}s 后自动重试 -> {self._endpoint}" ) else: self._disabled_until = float("inf") logger.warning( "Observability: Phoenix 导出已暂停," f"需手动重启或开启 Phoenix -> {self._endpoint}" ) def _endpoint_is_reachable(self) -> bool: try: parsed = urlparse(self._endpoint) host = parsed.hostname port = parsed.port if not host: return False if port is None: port = 443 if parsed.scheme == "https" else 80 with socket.create_connection((host, port), timeout=0.3): return True except OSError: return False def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: if not spans: return SpanExportResult.SUCCESS if self._should_skip_export(): return SpanExportResult.FAILURE try: result = self._exporter.export(spans) except Exception as e: self._handle_failure(e) return SpanExportResult.FAILURE if result == SpanExportResult.SUCCESS: self._handle_success() else: self._handle_failure(None) return result def shutdown(self) -> None: try: self._exporter.shutdown() except Exception as e: logger.warning(f"Observability: Phoenix 导出器关闭失败: {e}") def force_flush(self, timeout_millis: int = 30000) -> bool: try: return self._exporter.force_flush(timeout_millis) except Exception: return False ================================================ FILE: lifetrace/observability/setup.py ================================================ """Observability 初始化模块 负责设置 OpenTelemetry tracing 和 instrumentors。 """ from __future__ import annotations import importlib import logging import threading import warnings from typing import Any, cast from lifetrace.observability.config import get_observability_config from lifetrace.util.logging_config import get_logger logger = get_logger() def _suppress_otel_context_warnings(): """抑制 OpenTelemetry context detach 警告 这些警告在异步/生成器环境中是正常的,不影响功能。 警告来源:OpenTelemetry 的 context detach 在流式/生成器模式下 会因为 context 跨越不同的异步边界而触发。 """ # 1. 过滤 logging 模块的警告 class ContextDetachFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: msg = record.getMessage() if "Failed to detach context" in msg: return False return "was created in a different Context" not in msg # 应用到 OpenTelemetry 相关的 logger for logger_name in ["opentelemetry", "opentelemetry.context"]: otel_logger = logging.getLogger(logger_name) otel_logger.addFilter(ContextDetachFilter()) # 2. 过滤 warnings 模块 warnings.filterwarnings("ignore", message=".*was created in a different Context.*") # 3. 重定向 OpenTelemetry 的 stderr 输出(它直接打印到 stderr) # 通过 monkey-patch OpenTelemetry 的 detach 函数来抑制警告 try: otel_context = importlib.import_module("opentelemetry.context") otel_context_any = cast("Any", otel_context) _original_detach = otel_context_any.detach def _silent_detach(token): """静默版本的 detach,捕获并忽略 context 错误""" try: return _original_detach(token) except ValueError as e: if "was created in a different Context" in str(e): pass # 静默忽略这个已知问题 else: raise if hasattr(otel_context, "detach"): otel_context_any.detach = _silent_detach except Exception as e: logger.debug(f"Observability: patch OTel context 失败: {e}") # 全局初始化标志,确保只初始化一次 _initialized = threading.Event() _init_lock = threading.Lock() def _try_create_phoenix_exporter(config): """创建 Phoenix 导出器,失败时返回 (None, None)。""" try: exporter_module = importlib.import_module( "opentelemetry.exporter.otlp.proto.http.trace_exporter" ) otlp_span_exporter_class = exporter_module.OTLPSpanExporter except ImportError: logger.warning("Phoenix 导出器依赖未安装,跳过 Phoenix 集成") return None, None try: phoenix_endpoint = f"{config.phoenix.endpoint}/v1/traces" phoenix_module = importlib.import_module( "lifetrace.observability.exporters.phoenix_exporter" ) phoenix_circuit_breaker_exporter_class = phoenix_module.PhoenixCircuitBreakerExporter exporter = otlp_span_exporter_class( endpoint=phoenix_endpoint, timeout=config.phoenix.export_timeout_sec, ) safe_exporter = phoenix_circuit_breaker_exporter_class( exporter=exporter, endpoint=phoenix_endpoint, disable_after_failures=config.phoenix.disable_after_failures, retry_cooldown_sec=config.phoenix.retry_cooldown_sec, ) return safe_exporter, phoenix_endpoint except Exception as e: logger.warning(f"Phoenix 导出器初始化失败: {e}") return None, None def _setup_phoenix_exporter(tracer_provider, config) -> None: """设置 Phoenix 导出器""" phoenix_exporter, phoenix_endpoint = _try_create_phoenix_exporter(config) if phoenix_exporter is None or phoenix_endpoint is None: return exporter_module = importlib.import_module("opentelemetry.sdk.trace.export") simple_span_processor_class = exporter_module.SimpleSpanProcessor tracer_provider.add_span_processor(simple_span_processor_class(phoenix_exporter)) logger.info( "Observability: Phoenix 导出已启用 " f"(failures={config.phoenix.disable_after_failures}, " f"cooldown={config.phoenix.retry_cooldown_sec:.0f}s) -> {phoenix_endpoint}" ) def _setup_agno_instrumentor() -> None: """设置 Agno Instrumentor""" try: agno_module = importlib.import_module("openinference.instrumentation.agno") agno_instrumentor_class = agno_module.AgnoInstrumentor agno_instrumentor_class().instrument() logger.info("Observability: Agno Instrumentor 已启用") except ImportError: logger.warning("AgnoInstrumentor 未安装,跳过自动 instrument") except Exception as e: logger.warning(f"AgnoInstrumentor 初始化失败: {e}") def _setup_openai_instrumentor() -> None: """设置 OpenAI Instrumentor 用于追踪 Tool 内部的 LLM 调用,如 breakdown_task 中的 stream_chat。 这样可以在 Phoenix 中看到: - Tool 调用的总耗时 - 内部 LLM 调用的详细信息(模型、token、延迟等) """ try: openai_module = importlib.import_module("openinference.instrumentation.openai") openai_instrumentor_class = openai_module.OpenAIInstrumentor openai_instrumentor_class().instrument() logger.info("Observability: OpenAI Instrumentor 已启用") except ImportError: logger.warning("OpenAIInstrumentor 未安装,跳过 OpenAI 自动 instrument") except Exception as e: logger.warning(f"OpenAIInstrumentor 初始化失败: {e}") def setup_observability() -> bool: """初始化观测系统 根据配置设置 OpenTelemetry tracing,支持: - local: 本地 JSON 文件导出 - phoenix: Phoenix UI 导出 - both: 同时启用两者 Returns: bool: 是否成功初始化 """ with _init_lock: if _initialized.is_set(): return True config = get_observability_config() if not config.enabled: logger.debug("Observability 已禁用") return False # 抑制 OTel context 警告(在异步环境中是正常的) _suppress_otel_context_warnings() try: trace_api = importlib.import_module("opentelemetry.trace") trace_sdk = importlib.import_module("opentelemetry.sdk.trace") exporter_module = importlib.import_module("opentelemetry.sdk.trace.export") batch_span_processor_class = exporter_module.BatchSpanProcessor local_exporter_module = importlib.import_module( "lifetrace.observability.exporters.file_exporter" ) local_file_exporter_class = local_exporter_module.LocalFileExporter tracer_provider = trace_sdk.TracerProvider() # 本地文件导出 if config.mode in ("local", "both"): file_exporter = local_file_exporter_class( traces_dir=config.local.traces_dir, max_files=config.local.max_files, pretty_print=config.local.pretty_print, summary_only=config.terminal.summary_only, ) tracer_provider.add_span_processor(batch_span_processor_class(file_exporter)) logger.info(f"Observability: 本地文件导出已启用 -> {config.local.traces_dir}") # Phoenix 导出 if config.mode in ("phoenix", "both"): _setup_phoenix_exporter(tracer_provider, config) trace_api.set_tracer_provider(tracer_provider) _setup_agno_instrumentor() _setup_openai_instrumentor() # 追踪 Tool 内部的 LLM 调用 _initialized.set() logger.info(f"Observability 初始化成功,模式: {config.mode}") return True except ImportError as e: logger.warning(f"Observability 依赖未安装: {e}") return False except Exception as e: logger.error(f"Observability 初始化失败: {e}") return False def is_observability_enabled() -> bool: """检查观测系统是否已启用 Returns: bool: 是否已启用 """ return _initialized.is_set() ================================================ FILE: lifetrace/pyinstaller.spec ================================================ # -*- mode: python ; coding: utf-8 -*- """ PyInstaller spec file for LifeTrace backend Creates a one-folder bundle (recommended for large dependencies like PyTorch) """ import os import shutil import sys from pathlib import Path # Try to get the directory from SPECPATH (set by PyInstaller) try: # SPECPATH is automatically set by PyInstaller to the spec file's absolute path spec_path = Path(SPECPATH) lifetrace_dir = spec_path.resolve().parent except (NameError, AttributeError): # Fallback: use current working directory (should be lifetrace dir when script runs) lifetrace_dir = Path(os.getcwd()).resolve() # Verify the directory contains the expected structure config_file = lifetrace_dir / "config" / "default_config.yaml" if not config_file.exists(): # If config not found, try going up one level to find lifetrace directory # This handles the case where we're in a subdirectory potential_lifetrace = lifetrace_dir.parent / "lifetrace" if (potential_lifetrace / "config" / "default_config.yaml").exists(): lifetrace_dir = potential_lifetrace else: # Last resort: try to find it relative to current working directory cwd = Path(os.getcwd()) if (cwd / "config" / "default_config.yaml").exists(): lifetrace_dir = cwd elif (cwd / "lifetrace" / "config" / "default_config.yaml").exists(): lifetrace_dir = cwd / "lifetrace" # Final verification - raise error if still not found if not (lifetrace_dir / "config" / "default_config.yaml").exists(): raise FileNotFoundError( f"Cannot find config file. Tried: {lifetrace_dir / 'config' / 'default_config.yaml'}\n" f"SPECPATH: {SPECPATH if 'SPECPATH' in globals() else 'not set'}\n" f"CWD: {os.getcwd()}\n" f"Please ensure you run PyInstaller from the lifetrace directory or specify the correct path." ) def _env_flag(name: str, default: bool) -> bool: value = os.getenv(name) if value is None: return default return value.strip().lower() in {"1", "true", "yes", "on"} def _env_int(name: str, default: int) -> int: value = os.getenv(name) if value is None: return default try: return int(value) except ValueError: return default # Build options (override with env vars if needed) # LIFETRACE_INCLUDE_VECTOR=1 to include vector deps (chromadb/transformers/torch/etc.) include_vector = _env_flag("LIFETRACE_INCLUDE_VECTOR", False) optimize_level = _env_int("PYINSTALLER_OPTIMIZE", 1) enable_strip = _env_flag("PYINSTALLER_STRIP", sys.platform != "win32") enable_upx = _env_flag( "PYINSTALLER_UPX", bool(shutil.which("upx")) and sys.platform != "darwin", ) # Data files to include # 注意:config 和 models 放在 app 根目录(与 _internal 同级别),而不是 _internal 内 # 这样在打包环境中,路径为 backend/config/ 和 backend/models/ datas = [ # Configuration files - 放在 app 根目录下的 config/ (str(lifetrace_dir / "config" / "default_config.yaml"), "config"), (str(lifetrace_dir / "config" / "rapidocr_config.yaml"), "config"), # Prompts directory - 包含所有拆分后的 prompt yaml 文件 (str(lifetrace_dir / "config" / "prompts"), "config/prompts"), # ONNX model files - 放在 app 根目录下的 models/ (str(lifetrace_dir / "models" / "ch_PP-OCRv4_det_infer.onnx"), "models"), (str(lifetrace_dir / "models" / "ch_PP-OCRv4_rec_infer.onnx"), "models"), (str(lifetrace_dir / "models" / "ch_ppocr_mobile_v2.0_cls_infer.onnx"), "models"), ] # Hidden imports (modules that PyInstaller might miss) # 注意:这些模块需要与 pyproject.toml 中的依赖保持一致 hiddenimports = [ # LifeTrace core modules "lifetrace", "lifetrace.server", "lifetrace.util", "lifetrace.util.config", "lifetrace.util.logging_config", "lifetrace.routers", "lifetrace.storage", "lifetrace.llm", "lifetrace.jobs", "lifetrace.schemas", "lifetrace.services", # FastAPI and web server (fastapi, uvicorn) "fastapi", "uvicorn", "uvicorn.loops", "uvicorn.loops.auto", "uvicorn.protocols", "uvicorn.protocols.http", "uvicorn.protocols.http.auto", "uvicorn.protocols.websockets", "uvicorn.protocols.websockets.auto", "uvicorn.lifespan", "uvicorn.lifespan.on", "jinja2", # FastAPI 依赖 # Data validation and ORM (pydantic, sqlalchemy, sqlmodel, alembic) "pydantic", "pydantic.json", "sqlalchemy", "sqlalchemy.engine", "sqlalchemy.pool", "sqlalchemy.dialects.sqlite", "sqlmodel", "alembic", "alembic.config", "alembic.script", "alembic.runtime", "alembic.runtime.environment", "alembic.runtime.migration", # Screenshot and image processing (mss, Pillow, imagehash) "mss", "PIL", "PIL.Image", "imagehash", "cv2", # rapidocr 依赖 "numpy", "numpy._core", "numpy._core._multiarray_umath", "numpy._core.multiarray", "numpy._core.umath", "numpy._globals", "numpy._core._globals", # OCR processing (rapidocr-onnxruntime) "rapidocr_onnxruntime", "rapidocr_onnxruntime.main", "rapidocr_onnxruntime.cal_rec_boxes", "rapidocr_onnxruntime.ch_ppocr_cls", "rapidocr_onnxruntime.ch_ppocr_det", "rapidocr_onnxruntime.ch_ppocr_rec", "rapidocr_onnxruntime.utils", # Configuration (pyyaml, dynaconf) "yaml", "dynaconf", "dynaconf.loaders", "dynaconf.loaders.yaml_loader", "dynaconf.utils", "dynaconf.utils.boxing", "dynaconf.utils.parse_conf", "dynaconf.validator", # Scheduler (apscheduler) "apscheduler", "apscheduler.executors", "apscheduler.executors.pool", "apscheduler.jobstores", "apscheduler.jobstores.memory", "apscheduler.triggers", "apscheduler.triggers.cron", "apscheduler.triggers.interval", # Utils (psutil, openai, tavily) "psutil", "openai", "tavily", # Tavily API for web search "dateutil", # 可能被其他库依赖 "rich", # 可能被其他库依赖 # Logging (loguru) "loguru", "loguru._defaults", "loguru._handler", "loguru._logger", "loguru._recattrs", "loguru._file_sink", "loguru._colorizer", "loguru._contextvars", "loguru._get_frame", "loguru._simple_sink", "loguru._string_parsers", "loguru._writer", # Vector database and semantic search (可选 vector 组) - 按需添加 ] # 平台特定的 hidden imports if sys.platform == "darwin": # macOS specific (pyobjc-framework-Cocoa, pyobjc-framework-Quartz) hiddenimports.extend([ "objc", "AppKit", "Cocoa", "Quartz", "Quartz.CoreGraphics", "CoreFoundation", ]) elif sys.platform == "win32": # Windows specific (pywin32) hiddenimports.extend([ "win32api", "win32con", "win32gui", "win32process", "pywintypes", ]) # Collect all lifetrace source files to ensure they're included # PyInstaller needs the parent directory in pathex to find the lifetrace module lifetrace_parent_dir = str(lifetrace_dir.parent) # Collect data files and binaries from rapidocr_onnxruntime package # This ensures config.yaml and other data files are included from PyInstaller.utils.hooks import collect_data_files, collect_submodules # Collect all submodules to ensure nothing is missed rapidocr_submodules = collect_submodules("rapidocr_onnxruntime") hiddenimports.extend(rapidocr_submodules) # Collect data files (config.yaml, etc.) rapidocr_datas = collect_data_files("rapidocr_onnxruntime") datas.extend(rapidocr_datas) # Collect all chromadb submodules (including telemetry.product.posthog) # ChromaDB and sentence-transformers are optional; include only if enabled vector_modules = [ "torch", "torchvision", "torchaudio", "transformers", # sentence-transformers 依赖 "scipy", "hdbscan", "sentence_transformers", "chromadb", ] if include_vector: hiddenimports.extend(vector_modules) chromadb_submodules = collect_submodules("chromadb") hiddenimports.extend(chromadb_submodules) chromadb_datas = collect_data_files("chromadb") datas.extend(chromadb_datas) sentence_transformers_submodules = collect_submodules("sentence_transformers") hiddenimports.extend(sentence_transformers_submodules) sentence_transformers_datas = collect_data_files("sentence_transformers") datas.extend(sentence_transformers_datas) # Collect dynaconf submodules and data files (配置管理) dynaconf_submodules = collect_submodules("dynaconf") hiddenimports.extend(dynaconf_submodules) dynaconf_datas = collect_data_files("dynaconf") datas.extend(dynaconf_datas) # Collect sqlmodel submodules (ORM) sqlmodel_submodules = collect_submodules("sqlmodel") hiddenimports.extend(sqlmodel_submodules) # Collect alembic submodules and data files (数据库迁移) alembic_submodules = collect_submodules("alembic") hiddenimports.extend(alembic_submodules) alembic_datas = collect_data_files("alembic") datas.extend(alembic_datas) # Collect imagehash submodules (图像哈希) imagehash_submodules = collect_submodules("imagehash") hiddenimports.extend(imagehash_submodules) # Collect tavily submodules (Tavily API for web search) tavily_submodules = collect_submodules("tavily") hiddenimports.extend(tavily_submodules) # Collect tavily data files if any tavily_datas = collect_data_files("tavily") datas.extend(tavily_datas) # Collect numpy submodules (NumPy 2.x 需要显式收集子模块) # NumPy 2.4+ 与 PyInstaller 的兼容性问题,需要确保所有核心模块都被包含 numpy_submodules = collect_submodules("numpy") hiddenimports.extend(numpy_submodules) # 特别添加 numpy._core 和 numpy._globals 相关模块 numpy_core_submodules = collect_submodules("numpy._core") hiddenimports.extend(numpy_core_submodules) excludes = [ "matplotlib", "tkinter", "pytest", # 注意:不要排除 unittest,因为 imagehash 等库可能依赖它 # "unittest", "test", "tests", ] if not include_vector: excludes.extend(vector_modules) a = Analysis( ["scripts/start_backend.py"], pathex=[lifetrace_parent_dir, str(lifetrace_dir)], # Add both parent and lifetrace directory to Python path binaries=[], datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=excludes, noarchive=False, optimize=optimize_level, ) pyz = PYZ(a.pure, a.zipped_data, cipher=None) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name="lifetrace", debug=False, bootloader_ignore_signals=False, strip=enable_strip, upx=enable_upx, console=True, # Keep console for debugging disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=enable_strip, upx=enable_upx, upx_exclude=[], name="lifetrace", ) ================================================ FILE: lifetrace/repositories/__init__.py ================================================ ================================================ FILE: lifetrace/repositories/interfaces.py ================================================ """仓库接口定义模块 定义数据访问层的抽象接口,支持依赖注入和单元测试。 """ from abc import ABC, abstractmethod from datetime import datetime from typing import Any class IChatRepository(ABC): """Chat 仓库接口""" @abstractmethod def create_chat( self, session_id: str, chat_type: str = "event", title: str | None = None, context_id: int | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: """创建聊天会话""" pass @abstractmethod def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None: """根据 session_id 获取聊天会话""" pass @abstractmethod def list_chats( self, chat_type: str | None = None, limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: """列出聊天会话""" pass @abstractmethod def update_chat_title(self, session_id: str, title: str) -> bool: """更新聊天会话标题""" pass @abstractmethod def delete_chat(self, session_id: str) -> bool: """删除聊天会话及其所有消息""" pass @abstractmethod def add_message( self, session_id: str, role: str, content: str, token_count: int | None = None, model: str | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: """添加消息到聊天会话""" pass @abstractmethod def get_messages( self, session_id: str, limit: int | None = None, offset: int = 0, ) -> list[dict[str, Any]]: """获取聊天会话的消息列表""" pass @abstractmethod def get_message_count(self, session_id: str) -> int: """获取聊天会话的消息数量""" pass @abstractmethod def get_chat_summaries( self, chat_type: str | None = None, limit: int = 10, ) -> list[dict[str, Any]]: """获取聊天会话摘要列表""" pass @abstractmethod def get_chat_context(self, session_id: str) -> str | None: """获取会话上下文(JSON 字符串)""" pass @abstractmethod def update_chat_context(self, session_id: str, context: str) -> bool: """更新会话上下文 Args: session_id: 会话ID context: JSON 格式的上下文字符串 Returns: 是否更新成功 """ pass class ITodoRepository(ABC): """Todo 仓库接口""" @abstractmethod def get_by_id(self, todo_id: int) -> dict[str, Any] | None: """根据ID获取单个todo""" pass @abstractmethod def get_by_uid(self, uid: str) -> dict[str, Any] | None: """根据UID获取单个todo""" pass @abstractmethod def list_todos(self, limit: int, offset: int, status: str | None) -> list[dict[str, Any]]: """获取todo列表""" pass @abstractmethod def count(self, status: str | None) -> int: """统计todo数量""" pass @abstractmethod def create(self, **kwargs) -> int | None: """创建todo,返回ID""" pass @abstractmethod def update(self, todo_id: int, **kwargs) -> bool: """更新todo""" pass @abstractmethod def delete(self, todo_id: int) -> bool: """删除todo""" pass @abstractmethod def reorder(self, items: list[dict[str, Any]]) -> bool: """批量重排序""" pass @abstractmethod def add_attachment( self, *, todo_id: int, file_name: str, file_path: str, file_size: int | None, mime_type: str | None, file_hash: str | None, source: str = "user", ) -> dict[str, Any] | None: """新增附件并绑定到 todo""" pass @abstractmethod def remove_attachment(self, *, todo_id: int, attachment_id: int) -> bool: """解绑附件""" pass @abstractmethod def get_attachment(self, attachment_id: int) -> dict[str, Any] | None: """获取附件信息""" pass class IJournalRepository(ABC): """Journal 仓库接口""" @abstractmethod def get_by_id(self, journal_id: int) -> dict[str, Any] | None: """根据ID获取单个日记""" pass @abstractmethod def list_journals( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, ) -> list[dict[str, Any]]: """获取日记列表""" pass @abstractmethod def count(self, start_date: datetime | None, end_date: datetime | None) -> int: """统计日记数量""" pass @abstractmethod def create(self, payload: Any) -> int | None: """创建日记,返回ID""" pass @abstractmethod def update(self, journal_id: int, payload: Any) -> bool: """更新日记""" pass @abstractmethod def delete(self, journal_id: int) -> bool: """删除日记""" pass class IEventRepository(ABC): """Event 仓库接口""" @abstractmethod def get_summary(self, event_id: int) -> dict[str, Any] | None: """获取单个事件摘要""" pass @abstractmethod def list_events( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, app_name: str | None, ) -> list[dict[str, Any]]: """获取事件列表""" pass @abstractmethod def count_events( self, start_date: datetime | None, end_date: datetime | None, app_name: str | None, ) -> int: """统计事件数量""" pass @abstractmethod def get_screenshots(self, event_id: int) -> list[dict[str, Any]]: """获取事件关联的截图""" pass @abstractmethod def update_summary(self, event_id: int, ai_title: str, ai_summary: str) -> bool: """更新事件AI摘要""" pass @abstractmethod def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]: """批量获取事件""" pass class IOcrRepository(ABC): """OCR 仓库接口""" @abstractmethod def get_results_by_screenshot(self, screenshot_id: int) -> list[dict[str, Any]]: """获取截图的OCR结果""" pass class IActivityRepository(ABC): """Activity 仓库接口""" @abstractmethod def get_by_id(self, activity_id: int) -> dict[str, Any] | None: """根据ID获取活动""" pass @abstractmethod def get_activities( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, ) -> list[dict[str, Any]]: """获取活动列表""" pass @abstractmethod def count_activities( self, start_date: datetime | None, end_date: datetime | None, ) -> int: """统计活动数量""" pass @abstractmethod def get_activity_events(self, activity_id: int) -> list[int]: """获取活动关联的事件ID列表""" pass @abstractmethod def create_activity( self, start_time: datetime, end_time: datetime, ai_title: str, ai_summary: str, event_ids: list[int], ) -> int | None: """创建活动""" pass @abstractmethod def activity_exists_for_event_id(self, event_id: int) -> bool: """检查事件ID是否已关联到活动""" pass ================================================ FILE: lifetrace/repositories/sql_activity_repository.py ================================================ """基于 SQLAlchemy 的 Activity 仓库实现 复用现有的 ActivityManager 逻辑,提供符合仓库接口的数据访问层。 """ from datetime import datetime from typing import Any from lifetrace.repositories.interfaces import IActivityRepository from lifetrace.storage.activity_manager import ActivityManager from lifetrace.storage.database_base import DatabaseBase class SqlActivityRepository(IActivityRepository): """基于 SQLAlchemy 的 Activity 仓库实现""" def __init__(self, db_base: DatabaseBase): self._manager = ActivityManager(db_base) def get_by_id(self, activity_id: int) -> dict[str, Any] | None: return self._manager.get_activity(activity_id) def get_activities( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, ) -> list[dict[str, Any]]: return self._manager.get_activities( limit=limit, offset=offset, start_date=start_date, end_date=end_date, ) def count_activities( self, start_date: datetime | None, end_date: datetime | None, ) -> int: return self._manager.count_activities( start_date=start_date, end_date=end_date, ) def get_activity_events(self, activity_id: int) -> list[int]: return self._manager.get_activity_events(activity_id) def create_activity( self, start_time: datetime, end_time: datetime, ai_title: str, ai_summary: str, event_ids: list[int], ) -> int | None: return self._manager.create_activity( start_time=start_time, end_time=end_time, ai_title=ai_title, ai_summary=ai_summary, event_ids=event_ids, ) def activity_exists_for_event_id(self, event_id: int) -> bool: return self._manager.activity_exists_for_event_id(event_id) ================================================ FILE: lifetrace/repositories/sql_chat_repository.py ================================================ """基于 SQLAlchemy 的 Chat 仓库实现 复用现有的 ChatManager 逻辑,提供符合仓库接口的数据访问层。 """ from typing import Any from lifetrace.repositories.interfaces import IChatRepository from lifetrace.storage.chat_manager import ChatManager from lifetrace.storage.database_base import DatabaseBase class SqlChatRepository(IChatRepository): """基于 SQLAlchemy 的 Chat 仓库实现""" def __init__(self, db_base: DatabaseBase): # 复用现有的 ChatManager 逻辑 self._manager = ChatManager(db_base) def create_chat( self, session_id: str, chat_type: str = "event", title: str | None = None, context_id: int | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: return self._manager.create_chat( session_id=session_id, chat_type=chat_type, title=title, context_id=context_id, metadata=metadata, ) def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None: return self._manager.get_chat_by_session_id(session_id) def list_chats( self, chat_type: str | None = None, limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: return self._manager.list_chats( chat_type=chat_type, limit=limit, offset=offset, ) def update_chat_title(self, session_id: str, title: str) -> bool: return self._manager.update_chat_title(session_id, title) def delete_chat(self, session_id: str) -> bool: return self._manager.delete_chat(session_id) def add_message( self, session_id: str, role: str, content: str, token_count: int | None = None, model: str | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: return self._manager.add_message( session_id=session_id, role=role, content=content, token_count=token_count, model=model, metadata=metadata, ) def get_messages( self, session_id: str, limit: int | None = None, offset: int = 0, ) -> list[dict[str, Any]]: return self._manager.get_messages( session_id=session_id, limit=limit, offset=offset, ) def get_message_count(self, session_id: str) -> int: return self._manager.get_message_count(session_id) def get_chat_summaries( self, chat_type: str | None = None, limit: int = 10, ) -> list[dict[str, Any]]: return self._manager.get_chat_summaries( chat_type=chat_type, limit=limit, ) def get_chat_context(self, session_id: str) -> str | None: return self._manager.get_chat_context(session_id) def update_chat_context(self, session_id: str, context: str) -> bool: return self._manager.update_chat_context(session_id, context) ================================================ FILE: lifetrace/repositories/sql_event_repository.py ================================================ """基于 SQLAlchemy 的 Event 仓库实现 复用现有的 EventManager 和 OcrManager 逻辑,提供符合仓库接口的数据访问层。 """ from datetime import datetime from typing import Any from lifetrace.repositories.interfaces import IEventRepository, IOcrRepository from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.event_manager import EventManager from lifetrace.storage.ocr_manager import OCRManager class SqlEventRepository(IEventRepository): """基于 SQLAlchemy 的 Event 仓库实现""" def __init__(self, db_base: DatabaseBase): self._manager = EventManager(db_base) def get_summary(self, event_id: int) -> dict[str, Any] | None: return self._manager.get_event_summary(event_id) def list_events( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, app_name: str | None, ) -> list[dict[str, Any]]: return self._manager.list_events( limit=limit, offset=offset, start_date=start_date, end_date=end_date, app_name=app_name, ) def count_events( self, start_date: datetime | None, end_date: datetime | None, app_name: str | None, ) -> int: return self._manager.count_events( start_date=start_date, end_date=end_date, app_name=app_name, ) def get_screenshots(self, event_id: int) -> list[dict[str, Any]]: return self._manager.get_event_screenshots(event_id) def update_summary(self, event_id: int, ai_title: str, ai_summary: str) -> bool: return self._manager.update_event_summary(event_id, ai_title, ai_summary) def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]: return self._manager.get_events_by_ids(event_ids) class SqlOcrRepository(IOcrRepository): """基于 SQLAlchemy 的 OCR 仓库实现""" def __init__(self, db_base: DatabaseBase): self._manager = OCRManager(db_base) def get_results_by_screenshot(self, screenshot_id: int) -> list[dict[str, Any]]: return self._manager.get_ocr_results_by_screenshot(screenshot_id) ================================================ FILE: lifetrace/repositories/sql_journal_repository.py ================================================ """基于 SQLAlchemy 的 Journal 仓库实现 复用现有的 JournalManager 逻辑,提供符合仓库接口的数据访问层。 """ from datetime import datetime from typing import Any from lifetrace.repositories.interfaces import IJournalRepository from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.journal_manager import JournalManager class SqlJournalRepository(IJournalRepository): """基于 SQLAlchemy 的 Journal 仓库实现""" def __init__(self, db_base: DatabaseBase): self._manager = JournalManager(db_base) def get_by_id(self, journal_id: int) -> dict[str, Any] | None: return self._manager.get_journal(journal_id) def list_journals( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, ) -> list[dict[str, Any]]: return self._manager.list_journals( limit=limit, offset=offset, start_date=start_date, end_date=end_date, ) def count(self, start_date: datetime | None, end_date: datetime | None) -> int: return self._manager.count_journals(start_date=start_date, end_date=end_date) def create(self, payload: Any) -> int | None: return self._manager.create_journal(payload) def update(self, journal_id: int, payload: Any) -> bool: return self._manager.update_journal(journal_id, payload) def delete(self, journal_id: int) -> bool: return self._manager.delete_journal(journal_id) ================================================ FILE: lifetrace/repositories/sql_todo_repository.py ================================================ """基于 SQLAlchemy 的 Todo 仓库实现 复用现有的 TodoManager 逻辑,提供符合仓库接口的数据访问层。 """ from typing import Any from lifetrace.repositories.interfaces import ITodoRepository from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.todo_manager import TodoManager class SqlTodoRepository(ITodoRepository): """基于 SQLAlchemy 的 Todo 仓库实现""" def __init__(self, db_base: DatabaseBase): # 复用现有的 TodoManager 逻辑 self._manager = TodoManager(db_base) def get_by_id(self, todo_id: int) -> dict[str, Any] | None: return self._manager.get_todo(todo_id) def get_by_uid(self, uid: str) -> dict[str, Any] | None: return self._manager.get_todo_by_uid(uid) def list_todos(self, limit: int, offset: int, status: str | None) -> list[dict[str, Any]]: return self._manager.list_todos(limit=limit, offset=offset, status=status) def count(self, status: str | None) -> int: return self._manager.count_todos(status=status) def create(self, **kwargs) -> int | None: return self._manager.create_todo(**kwargs) def update(self, todo_id: int, **kwargs) -> bool: return self._manager.update_todo(todo_id, **kwargs) def delete(self, todo_id: int) -> bool: return self._manager.delete_todo(todo_id) def reorder(self, items: list[dict[str, Any]]) -> bool: return self._manager.reorder_todos(items) def add_attachment( self, *, todo_id: int, file_name: str, file_path: str, file_size: int | None, mime_type: str | None, file_hash: str | None, source: str = "user", ) -> dict[str, Any] | None: return self._manager.add_todo_attachment( todo_id=todo_id, file_name=file_name, file_path=file_path, file_size=file_size, mime_type=mime_type, file_hash=file_hash, source=source, ) def remove_attachment(self, *, todo_id: int, attachment_id: int) -> bool: return self._manager.remove_todo_attachment( todo_id=todo_id, attachment_id=attachment_id, ) def get_attachment(self, attachment_id: int) -> dict[str, Any] | None: return self._manager.get_attachment(attachment_id) ================================================ FILE: lifetrace/routers/activity.py ================================================ """活动相关路由""" from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from lifetrace.core.dependencies import get_activity_service from lifetrace.schemas.activity import ( ActivityEventsResponse, ActivityListResponse, ManualActivityCreateRequest, ManualActivityCreateResponse, ) from lifetrace.services.activity_service import ActivityService from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api/activities", tags=["activity"]) @router.get("", response_model=ActivityListResponse) async def list_activities( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), start_date: str | None = Query(None), end_date: str | None = Query(None), service: ActivityService = Depends(get_activity_service), ): """获取活动列表(活动=聚合的事件窗口)""" try: start_dt = datetime.fromisoformat(start_date) if start_date else None end_dt = datetime.fromisoformat(end_date) if end_date else None return service.list_activities( limit=limit, offset=offset, start_date=start_dt, end_date=end_dt, ) except Exception as e: logger.error(f"获取活动列表失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/{activity_id}/events", response_model=ActivityEventsResponse) async def get_activity_events( activity_id: int, service: ActivityService = Depends(get_activity_service), ): """获取指定活动关联的事件ID列表""" try: return service.get_activity_events(activity_id) except Exception as e: logger.error(f"获取活动 {activity_id} 的事件列表失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/manual", response_model=ManualActivityCreateResponse, status_code=201) async def create_activity_manual( request: ManualActivityCreateRequest, service: ActivityService = Depends(get_activity_service), ): """手动聚合指定事件集合为活动 Args: request: 包含事件ID列表的请求 Returns: 创建的活动信息 """ try: return service.create_activity_manual(request) except HTTPException: raise except Exception as e: logger.error(f"手动聚合活动失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"手动聚合活动失败: {e!s}") from e ================================================ FILE: lifetrace/routers/audio.py ================================================ """音频录制和转录相关路由""" import json import time from datetime import date as date_type from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from fastapi import APIRouter, Query from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel, Field from sqlmodel import select from lifetrace.routers.audio_ws import register_audio_ws_routes from lifetrace.services.asr_client import ASRClient from lifetrace.services.audio_service import AudioService from lifetrace.storage import get_session from lifetrace.storage.models import AudioRecording, Transcription from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() router = APIRouter(prefix="/api/audio", tags=["audio"]) # 全局服务实例 asr_client = ASRClient() audio_service = AudioService() register_audio_ws_routes( router=router, logger=logger, asr_client=asr_client, audio_service=audio_service ) def _to_local(dt: datetime | None) -> datetime | None: """将时间转换为本地时区(带偏移),并返回 timezone-aware datetime。""" if dt is None: return None if dt.tzinfo is None: offset = -time.timezone if time.daylight == 0 else -time.altzone local_tz = timezone(timedelta(seconds=offset)) return dt.replace(tzinfo=local_tz) return dt.astimezone() @router.get("/recordings") async def get_recordings(date: str | None = Query(None)): """获取录音列表""" try: if date: # 处理日期字符串,支持多种格式 try: # 尝试解析ISO格式 if "T" in date or "Z" in date: target_date = datetime.fromisoformat(date.replace("Z", "+00:00")) else: # 处理 YYYY-MM-DD 格式 date_obj = date_type.fromisoformat(date) target_date = datetime.combine(date_obj, datetime.min.time()) except ValueError as e: logger.error(f"日期格式错误: {date}, {e}") return JSONResponse({"error": f"无效的日期格式: {date}"}, status_code=400) else: target_date = get_utc_now().astimezone() recordings = audio_service.get_recordings_by_date(target_date) result = [] for rec in recordings: if not rec: continue start_time = rec["start_time"] result.append( { "id": rec["id"], "date": start_time.strftime("%m月%d日 录音"), "time": start_time.strftime("%H:%M"), "duration": f"{int(rec['duration'] // 60)}:{int(rec['duration'] % 60):02d}", "durationSeconds": float(rec["duration"]), "size": f"{rec['file_size'] / 1024:.1f} KB", "isCurrent": rec["status"] == "recording", } ) return JSONResponse({"recordings": result}) except Exception as e: logger.error(f"获取录音列表失败: {e}", exc_info=True) return JSONResponse({"error": str(e)}, status_code=500) def _parse_date_param(date: str | None) -> datetime: """解析日期参数""" if date: try: if "T" in date or "Z" in date: return datetime.fromisoformat(date.replace("Z", "+00:00")) else: date_obj = date_type.fromisoformat(date) return datetime.combine(date_obj, datetime.min.time()) except ValueError as e: logger.error(f"日期格式错误: {date}, {e}") raise ValueError(f"无效的日期格式: {date}") from e else: return get_utc_now().astimezone() def _build_timeline_item( rec: dict[str, Any], transcription: dict[str, Any] | None, optimized: bool ) -> dict[str, Any]: """构建时间线项""" text = "" segment_timestamps: list[float] | None = None if transcription: if optimized and transcription.get("optimized_text"): text = transcription.get("optimized_text") or "" else: text = transcription.get("original_text") or "" # 解析时间戳(如果存在) timestamps_str = transcription.get("segment_timestamps") if timestamps_str: try: segment_timestamps = json.loads(timestamps_str) if not isinstance(segment_timestamps, list): segment_timestamps = None except (json.JSONDecodeError, TypeError): segment_timestamps = None start_local = _to_local(rec["start_time"]) timeline_item: dict[str, Any] = { "id": rec["id"], "start_time": (start_local or rec["start_time"]).isoformat(), "duration": float(rec["duration"]), "text": text, } # 如果有时间戳,添加到返回数据中 if segment_timestamps: timeline_item["segment_timestamps"] = segment_timestamps return timeline_item @router.get("/timeline") async def get_timeline(date: str | None = Query(None), optimized: bool = Query(False)): """按日期返回录音时间线(含转录文本)""" try: target_date = _parse_date_param(date) recordings = audio_service.get_recordings_by_date(target_date) timeline: list[dict[str, Any]] = [] for rec in recordings: if not rec: continue transcription = audio_service.get_transcription(int(rec["id"])) timeline_item = _build_timeline_item(rec, transcription, optimized) timeline.append(timeline_item) return JSONResponse({"timeline": timeline}) except ValueError as e: return JSONResponse({"error": str(e)}, status_code=400) except Exception as e: logger.error(f"获取时间线失败: {e}", exc_info=True) return JSONResponse({"error": str(e)}, status_code=500) @router.get("/recording/{recording_id}/file") async def get_recording_file(recording_id: int): """获取录音文件(用于前端播放)""" try: with get_session() as session: rec = session.get(AudioRecording, recording_id) if not rec or not rec.file_path: return JSONResponse({"error": "录音不存在"}, status_code=404) file_path = Path(rec.file_path) if not file_path.exists(): logger.error(f"录音文件不存在: {file_path}") return JSONResponse({"error": "录音文件不存在或已被删除"}, status_code=404) return FileResponse( path=str(file_path), media_type="audio/wav", filename=file_path.name, ) except Exception as e: logger.error(f"获取录音文件失败: {e}", exc_info=True) return JSONResponse({"error": str(e)}, status_code=500) def _load_extracted_json(transcription: dict[str, Any], field: str) -> list[dict[str, Any]]: """从转录数据中加载 JSON 字段。 Args: transcription: 转录数据字典 field: 字段名 Returns: 解析后的列表,如果解析失败则返回空列表 """ value = transcription.get(field) if not value: return [] try: return json.loads(value) except Exception: return [] def _refresh_extracted_from_db( transcription_id: int, recording_id: int, optimized: bool ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """从数据库刷新提取结果(只读取,不清空)。 Args: transcription_id: 转录ID recording_id: 录音ID optimized: 是否使用优化文本的提取结果 Returns: (todos, schedules) 元组 """ _ = transcription_id try: # 直接读取数据库,不要调用 update_extraction(会清空数据) refreshed = audio_service.get_transcription(recording_id) if not refreshed: return [], [] if optimized: todos = _load_extracted_json(refreshed, "extracted_todos_optimized") schedules = _load_extracted_json(refreshed, "extracted_schedules_optimized") else: todos = _load_extracted_json(refreshed, "extracted_todos") schedules = _load_extracted_json(refreshed, "extracted_schedules") return todos, schedules except Exception: return [], [] def _parse_extracted( transcription: dict[str, Any], optimized: bool = False, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """Parse extracted todos/schedules and backfill legacy fields. Args: transcription: 转录数据字典 optimized: 是否使用优化文本的提取结果 Returns: (todos, schedules) 元组 """ if optimized: todos = _load_extracted_json(transcription, "extracted_todos_optimized") schedules = _load_extracted_json(transcription, "extracted_schedules_optimized") else: todos = _load_extracted_json(transcription, "extracted_todos") schedules = _load_extracted_json(transcription, "extracted_schedules") # Backfill legacy items and persist so clients always get id/dedupe_key/linked refreshed_todos, refreshed_schedules = _refresh_extracted_from_db( int(transcription["id"]), transcription["audio_recording_id"], optimized ) if refreshed_todos or refreshed_schedules: return refreshed_todos, refreshed_schedules return todos, schedules @router.get("/transcription/{recording_id}") async def get_transcription(recording_id: int, optimized: bool = Query(False)): """获取转录文本""" try: transcription = audio_service.get_transcription(recording_id) if not transcription: return JSONResponse({"error": "转录不存在"}, status_code=404) text = transcription["optimized_text"] if optimized else transcription["original_text"] if not text: text = "" # 根据 optimized 参数选择对应的提取结果 todos, schedules = _parse_extracted(transcription, optimized=optimized) return JSONResponse( { "text": text, "recording_id": recording_id, "todos": todos, "schedules": schedules, } ) except Exception as e: logger.error(f"获取转录文本失败: {e}") return JSONResponse({"error": str(e)}, status_code=500) class AudioLinkItem(BaseModel): kind: str = Field(..., description="todo|schedule") item_id: str = Field(..., description="extracted item id") todo_id: int = Field(..., description="linked todo id") class AudioLinkRequest(BaseModel): links: list[AudioLinkItem] @router.post("/transcription/{recording_id}/link") async def link_extracted_items( recording_id: int, request: AudioLinkRequest, optimized: bool = Query(False) ): """Mark extracted items as linked to todos (persisted in transcription JSON). Args: recording_id: 录音ID request: 链接请求 optimized: 是否更新优化文本的提取结果 """ try: result = audio_service.extraction_service.link_extracted_items( recording_id=recording_id, links=[link.model_dump() for link in request.links], optimized=optimized, ) return JSONResponse(result) except Exception as e: logger.error(f"标记提取项已关联失败: {e}", exc_info=True) return JSONResponse({"error": str(e)}, status_code=500) @router.post("/optimize") async def optimize_transcription(recording_id: int): """优化转录文本(使用LLM)""" try: transcription = audio_service.get_transcription(recording_id) if not transcription: return JSONResponse({"error": "转录不存在"}, status_code=404) text = transcription.get("original_text") or "" if not text: return JSONResponse({"error": "转录文本为空"}, status_code=400) # 使用LLM优化 optimized_text = await audio_service.optimize_transcription_text(text) # 更新转录记录(保留提取结果) with get_session() as session: # 获取 ORM 对象(不是字典) trans = session.exec( select(Transcription) .where(Transcription.audio_recording_id == recording_id) .order_by(col(Transcription.id).desc()) ).first() if trans: # 只更新优化文本,保留提取结果等其他字段 trans.optimized_text = optimized_text session.add(trans) session.commit() return JSONResponse({"optimized_text": optimized_text}) except Exception as e: logger.error(f"优化转录文本失败: {e}") return JSONResponse({"error": str(e)}, status_code=500) @router.post("/extract") async def extract_todos_and_schedules(recording_id: int, optimized: bool = Query(False)): """提取待办事项和日程安排 Args: recording_id: 录音ID optimized: 是否从优化文本提取(False=从原文提取) """ try: transcription = audio_service.get_transcription(recording_id) if not transcription: return JSONResponse({"error": "转录不存在"}, status_code=404) text = ( transcription.get("optimized_text") or "" if optimized else transcription.get("original_text") or "" ) if not text: return JSONResponse({"error": "转录文本为空"}, status_code=400) # 使用LLM提取 result = await audio_service.extraction_service.extract_todos_and_schedules(text) # 更新提取结果(根据 optimized 参数更新对应字段) with get_session() as session: # 查询转录记录(一个 recording_id 只应该有一条) trans = session.exec( select(Transcription) .where(Transcription.audio_recording_id == recording_id) .order_by(col(Transcription.id).desc()) ).first() if trans and trans.id is not None: audio_service.update_extraction( transcription_id=trans.id, todos=result.get("todos", []), schedules=result.get("schedules", []), optimized=optimized, ) return JSONResponse(result) except Exception as e: logger.error(f"提取待办和日程失败: {e}") return JSONResponse({"error": str(e)}, status_code=500) ================================================ FILE: lifetrace/routers/audio_ws.py ================================================ """Audio websocket routes (recording + realtime ASR + realtime NLP). Split from `lifetrace.routers.audio` to keep router files small and readable. """ from __future__ import annotations import array import asyncio import importlib import json import struct import time from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any from fastapi import APIRouter, WebSocket, WebSocketDisconnect from starlette.websockets import WebSocketState from lifetrace.util.time_utils import get_utc_now if TYPE_CHECKING: from collections.abc import Callable # ---- constants (avoid magic numbers) ---- SAMPLE_RATE = 16000 NUM_CHANNELS = 1 BITS_PER_SAMPLE = 16 PCM_SILENCE_MAX_ABS = 50 PCM_SILENCE_RMS = 20 MAX_AGC_GAIN = 4.0 AGC_APPLY_THRESHOLD_GAIN = 1.05 INT16_MAX = 32767 INT16_MIN = -32768 AGC_TARGET_PEAK_RATIO = 0.85 # 分段存储配置 SEGMENT_DURATION_MINUTES = 30 # 30分钟分段 SILENCE_DETECTION_THRESHOLD_SECONDS = 600 # 10分钟静音检测阈值 SILENCE_CHECK_INTERVAL_SECONDS = 60 # 每60秒检查一次静音 def _track_task(task_set: set[asyncio.Task], coro) -> asyncio.Task: task = asyncio.create_task(coro) task_set.add(task) task.add_done_callback(task_set.discard) return task def _to_local(dt: datetime | None) -> datetime | None: """Convert datetime to local timezone (timezone-aware).""" if dt is None: return None if dt.tzinfo is None: offset = -time.timezone if time.daylight == 0 else -time.altzone local_tz = timezone(timedelta(seconds=offset)) return dt.replace(tzinfo=local_tz) return dt.astimezone() def _pcm16le_to_wav( pcm_data: bytes, sample_rate: int = SAMPLE_RATE, num_channels: int = NUM_CHANNELS, bits_per_sample: int = BITS_PER_SAMPLE, ) -> bytes: """Wrap raw PCM16LE bytes into WAV container bytes.""" byte_rate = sample_rate * num_channels * bits_per_sample // 8 block_align = num_channels * bits_per_sample // 8 data_size = len(pcm_data) fmt_chunk_size = 16 riff_chunk_size = 4 + (8 + fmt_chunk_size) + (8 + data_size) header = b"RIFF" header += struct.pack(" Callable[[str, bool], None]: """Create ASR result callback. NOTE: Only commit final sentences to `transcription_text_ref` to avoid duplicates. """ async def _send_result(text: str, is_final: bool) -> None: try: if ( is_connected_ref[0] and websocket.application_state == WebSocketState.CONNECTED and websocket.client_state == WebSocketState.CONNECTED ): await websocket.send_json( { "header": {"name": "TranscriptionResultChanged"}, "payload": {"result": text, "is_final": is_final}, } ) except Exception as e: is_connected_ref[0] = False logger.warning(f"Failed to send TranscriptionResultChanged to client: {e}") def on_result(text: str, is_final: bool) -> None: if not text or not is_connected_ref[0]: return if is_final: committed = transcription_text_ref[0] needs_gap = committed and not committed.endswith("\n") committed += ("\n" if needs_gap else "") + text transcription_text_ref[0] = committed try: if ( is_connected_ref[0] and websocket.application_state == WebSocketState.CONNECTED and websocket.client_state == WebSocketState.CONNECTED ): _track_task(task_set, _send_result(text, is_final)) except Exception as e: logger.warning(f"Failed to schedule sending TranscriptionResultChanged: {e}") return on_result def _create_error_callback( *, websocket: WebSocket, logger, is_connected_ref: list[bool], task_set: set[asyncio.Task] ): async def _send_error(error: Exception) -> None: try: if ( is_connected_ref[0] and websocket.application_state == WebSocketState.CONNECTED and websocket.client_state == WebSocketState.CONNECTED ): await websocket.send_json( {"header": {"name": "TaskFailed"}, "payload": {"error": str(error)}} ) except Exception as e: is_connected_ref[0] = False logger.warning(f"Failed to send TaskFailed to client: {e}") def on_error(error: Exception) -> None: logger.error(f"ASR转录错误: {error}") if is_connected_ref[0]: try: if ( websocket.application_state == WebSocketState.CONNECTED and websocket.client_state == WebSocketState.CONNECTED ): _track_task(task_set, _send_error(error)) except Exception as e: logger.warning(f"Failed to schedule sending TaskFailed: {e}") return on_error def _create_realtime_nlp_handler( # noqa: C901 *, websocket: WebSocket, logger, audio_service, is_connected_ref: list[bool], task_set: set[asyncio.Task], throttle_seconds: float = 8.0, ): """Realtime optimize/extract during recording (only on final sentences).""" class _RealtimeNlpThrottler: def __init__(self): self._buffer = "" self._last_emit = 0.0 self._pending: asyncio.Task | None = None async def _send(self, name: str, payload: dict[str, Any]) -> None: try: if not is_connected_ref[0]: logger.info(f"Skip sending {name}: is_connected_ref=False") return if not ( websocket.application_state == WebSocketState.CONNECTED and websocket.client_state == WebSocketState.CONNECTED ): logger.info( f"Skip sending {name}: websocket state not CONNECTED " f"(application_state={websocket.application_state}, client_state={websocket.client_state})" ) return await websocket.send_json({"header": {"name": name}, "payload": payload}) logger.debug(f"Sent {name} to client") except Exception as e: is_connected_ref[0] = False logger.warning(f"Failed to send {name} to client: {e}") async def _compute(self, text_snapshot: str) -> tuple[str, dict[str, Any]]: optimized = text_snapshot extracted: dict[str, Any] = {"todos": [], "schedules": []} try: optimized = await audio_service.optimize_transcription_text(text_snapshot) except Exception as e: logger.error(f"实时优化失败: {e}") try: extracted = await audio_service.extraction_service.extract_todos_and_schedules( text_snapshot ) except Exception as e: logger.error(f"实时提取失败: {e}") return optimized, extracted async def _run_once(self) -> None: text_snapshot = self._buffer.strip() if not text_snapshot: return optimized, extracted = await self._compute(text_snapshot) preview = optimized.replace("\n", " ")[:200] todos_preview = extracted.get("todos", []) schedules_preview = extracted.get("schedules", []) logger.info("实时优化/提取完成,准备推送给前端") logger.info(f"优化预览: {preview}") logger.info(f"提取结果: todos={todos_preview}, schedules={schedules_preview}") await self._send("OptimizedTextChanged", {"text": optimized}) await self._send( "ExtractionChanged", {"todos": extracted.get("todos", []), "schedules": extracted.get("schedules", [])}, ) async def _debounced_run(self, delay: float) -> None: try: await asyncio.sleep(delay) await self._run_once() finally: self._pending = None def on_final_sentence(self, text: str) -> None: if not text: return if self._buffer: self._buffer += "\n" self._buffer += text.strip() now = asyncio.get_event_loop().time() elapsed = now - self._last_emit if elapsed >= throttle_seconds: self._last_emit = now _track_task(task_set, self._run_once()) return if self._pending is None: delay = max(0.0, throttle_seconds - elapsed) self._pending = _track_task(task_set, self._debounced_run(delay)) def cancel(self) -> None: if self._pending and not self._pending.done(): self._pending.cancel() self._pending = None throttler = _RealtimeNlpThrottler() return throttler.on_final_sentence, throttler.cancel def _handle_websocket_text_message( message: dict, logger, segment_timestamps_ref: list[list[float] | None], should_segment_ref: list[bool] | None = None, ) -> bool: """处理 WebSocket 文本消息,返回是否应该停止流。 Returns: True 如果应该停止流,False 如果继续 """ msg_type = message.get("type") if msg_type == "stop": segment_timestamps_from_frontend = message.get("segment_timestamps", []) if isinstance(segment_timestamps_from_frontend, list): segment_timestamps_ref[0] = segment_timestamps_from_frontend logger.info( f"Received stop signal from client with {len(segment_timestamps_from_frontend)} segment timestamps" ) else: logger.info("Received stop signal from client") return True if msg_type == "segment" and should_segment_ref: # 客户端请求分段(用于手动分段或同步) should_segment_ref[0] = True logger.info("Received segment request from client") return False async def _audio_stream_generator( websocket: WebSocket, logger, audio_chunks: list[bytes], segment_timestamps_ref: list[list[float] | None], should_segment_ref: list[bool] | None = None, ): """Yield audio bytes from websocket until stop signal. Args: segment_timestamps_ref: 用于存储从客户端接收的时间戳数组的引用 should_segment_ref: 用于标记是否需要分段(外部可以设置此标志来触发分段) """ while True: try: data = await websocket.receive() if "bytes" in data: chunk = data["bytes"] if chunk: audio_chunks.append(chunk) yield chunk continue if "text" in data: try: message = json.loads(data["text"]) should_stop = _handle_websocket_text_message( message, logger, segment_timestamps_ref, should_segment_ref ) if should_stop: break except json.JSONDecodeError: logger.debug(f"Ignoring non-JSON text message: {data.get('text', '')[:50]}") continue except WebSocketDisconnect: logger.info("WebSocket disconnected in audio stream generator") break except Exception as e: logger.error(f"Error in audio stream generator: {e}") break def _parse_init_message(logger, init_message: dict[str, Any]) -> bool: logger.info(f"Received init message: {init_message}") return bool(init_message.get("is_24x7", False)) def _apply_agc_to_pcm(logger, pcm_bytes: bytes) -> bytes: try: samples = array.array("h") samples.frombytes(pcm_bytes) if not samples: return pcm_bytes max_abs = max(abs(s) for s in samples) rms = (sum(s * s for s in samples) / len(samples)) ** 0.5 logger.info(f"录音原始PCM: samples={len(samples)}, max_abs={max_abs}, rms={rms:.2f}") if max_abs < PCM_SILENCE_MAX_ABS and rms < PCM_SILENCE_RMS: logger.warning("录音PCM振幅极低,可能无声;请检查麦克风/权限/设备输入。") return pcm_bytes target_peak = AGC_TARGET_PEAK_RATIO * INT16_MAX gain = target_peak / max_abs if max_abs > 0 else 1.0 gain = min(gain, MAX_AGC_GAIN) if gain <= AGC_APPLY_THRESHOLD_GAIN: return pcm_bytes logger.info(f"应用自动增益: x{gain:.2f}") for i in range(len(samples)): v = int(samples[i] * gain) if v > INT16_MAX: v = INT16_MAX elif v < INT16_MIN: v = INT16_MIN samples[i] = v return samples.tobytes() except Exception as e: logger.debug(f"音量检测失败: {e}") return pcm_bytes def _detect_silence( pcm_bytes: bytes, threshold_max_abs: int = PCM_SILENCE_MAX_ABS, threshold_rms: float = PCM_SILENCE_RMS, ) -> bool: """检测音频是否为静音 Args: pcm_bytes: PCM音频数据 threshold_max_abs: 最大振幅阈值 threshold_rms: RMS阈值 Returns: True if silent, False otherwise """ try: samples = array.array("h") samples.frombytes(pcm_bytes) if not samples: return True max_abs = max(abs(s) for s in samples) rms = (sum(s * s for s in samples) / len(samples)) ** 0.5 return max_abs < threshold_max_abs and rms < threshold_rms except Exception: return False def _persist_recording( *, logger, audio_service, audio_chunks: list[bytes], recording_started_at: datetime, is_24x7: bool, ) -> tuple[int | None, float | None]: if not audio_chunks: return None, None pcm_bytes = b"".join(audio_chunks) duration = (get_utc_now() - recording_started_at).total_seconds() pcm_bytes = _apply_agc_to_pcm(logger, pcm_bytes) wav_bytes = _pcm16le_to_wav(pcm_bytes) file_path = audio_service.generate_audio_file_path(recording_started_at) file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_bytes(wav_bytes) recording_id = audio_service.create_recording( file_path=str(file_path), file_size=len(wav_bytes), duration=duration, is_24x7=is_24x7, ) audio_service.complete_recording(recording_id) return recording_id, duration async def _save_transcription_if_any( *, audio_service, recording_id: int | None, text: str, segment_timestamps: list[float] | None = None, ) -> None: if not recording_id or not text: return await audio_service.save_transcription( recording_id=recording_id, original_text=text, auto_optimize=True, segment_timestamps=segment_timestamps, ) # 导入分段相关功能(延迟导入以避免循环依赖) def _get_segment_functions(): """延迟导入分段函数以避免循环依赖""" segment_module = importlib.import_module("lifetrace.routers.audio_ws_segment") return segment_module._save_current_segment, segment_module._segment_monitor_task # 导入 WebSocket 处理函数(延迟导入以避免循环依赖) def _get_transcribe_handler(): """延迟导入 WebSocket 处理函数以避免循环依赖""" handler_module = importlib.import_module("lifetrace.routers.audio_ws_handler") return handler_module._handle_transcribe_ws def register_audio_ws_routes(*, router: APIRouter, logger, asr_client, audio_service) -> None: """Register websocket endpoints onto the given router.""" @router.websocket("/transcribe") async def websocket_transcribe(websocket: WebSocket) -> None: _handle_transcribe_ws = _get_transcribe_handler() await _handle_transcribe_ws( websocket=websocket, logger=logger, asr_client=asr_client, audio_service=audio_service ) ================================================ FILE: lifetrace/routers/audio_ws_handler.py ================================================ """Audio websocket handler logic. Split from `audio_ws.py` to reduce file size and complexity. """ from __future__ import annotations import asyncio import contextlib import importlib import json from fastapi import WebSocket, WebSocketDisconnect from lifetrace.util.time_utils import get_utc_now async def _handle_json_error(websocket: WebSocket, logger, e: json.JSONDecodeError) -> None: """处理 JSON 解析错误""" logger.error(f"Failed to parse WebSocket message: {e}") with contextlib.suppress(Exception): await websocket.close(code=1003, reason="Invalid message format") async def _handle_websocket_error(websocket: WebSocket, logger, e: Exception) -> None: """处理 WebSocket 错误""" logger.error(f"WebSocket error: {e}", exc_info=True) with contextlib.suppress(Exception): await websocket.close(code=1011, reason=str(e)) class _RunTranscriptionStreamContext: """运行转录流的上下文,用于减少参数数量""" def __init__(self, **kwargs): self.asr_client = kwargs["asr_client"] self.websocket = kwargs["websocket"] self.logger = kwargs["logger"] self.audio_chunks = kwargs["audio_chunks"] self.segment_timestamps_ref = kwargs["segment_timestamps_ref"] self.should_segment_ref = kwargs["should_segment_ref"] self.on_result = kwargs["on_result"] self.on_error = kwargs["on_error"] async def _run_transcription_stream(*, ctx: _RunTranscriptionStreamContext) -> None: """运行 ASR 转录流""" audio_ws_module = importlib.import_module("lifetrace.routers.audio_ws") audio_stream = audio_ws_module._audio_stream_generator( websocket=ctx.websocket, logger=ctx.logger, audio_chunks=ctx.audio_chunks, segment_timestamps_ref=ctx.segment_timestamps_ref, should_segment_ref=ctx.should_segment_ref, ) await ctx.asr_client.transcribe_stream( audio_stream=audio_stream, on_result=ctx.on_result, on_error=ctx.on_error, ) def _get_audio_ws_functions(): """延迟导入 audio_ws 模块的函数""" audio_ws_module = importlib.import_module("lifetrace.routers.audio_ws") return { "_audio_stream_generator": audio_ws_module._audio_stream_generator, "_create_error_callback": audio_ws_module._create_error_callback, "_create_realtime_nlp_handler": audio_ws_module._create_realtime_nlp_handler, "_create_result_callback": audio_ws_module._create_result_callback, "_get_segment_functions": audio_ws_module._get_segment_functions, "_handle_json_error": _handle_json_error, "_handle_websocket_error": _handle_websocket_error, "_parse_init_message": audio_ws_module._parse_init_message, "_persist_recording": audio_ws_module._persist_recording, "_run_transcription_stream": _run_transcription_stream, "_save_transcription_if_any": audio_ws_module._save_transcription_if_any, } class _SaveFinalDataContext: """保存最终数据的上下文,用于减少参数数量""" def __init__(self, **kwargs): self.data_saved_ref = kwargs["data_saved_ref"] self.stop_segment_task_func = kwargs["stop_segment_task_func"] self.audio_chunks = kwargs["audio_chunks"] self.transcription_text_ref = kwargs["transcription_text_ref"] self.segment_timestamps_ref = kwargs["segment_timestamps_ref"] self.recording_started_at = kwargs["recording_started_at"] self.is_24x7_ref = kwargs["is_24x7_ref"] self.audio_service = kwargs["audio_service"] self.logger = kwargs["logger"] self._persist_recording = kwargs["_persist_recording"] self._save_transcription_if_any = kwargs["_save_transcription_if_any"] async def _save_final_data_internal(*, ctx: _SaveFinalDataContext) -> None: """保存最终数据(确保只执行一次)""" if ctx.data_saved_ref[0]: return ctx.data_saved_ref[0] = True try: await ctx.stop_segment_task_func() # 检查是否有数据需要保存 if not ctx.audio_chunks and not ctx.transcription_text_ref[0]: ctx.logger.info("无数据需要保存") return ctx.logger.info( f"保存最终数据: audio_chunks={len(ctx.audio_chunks)}, text_len={len(ctx.transcription_text_ref[0])}" ) # 保存最后一段 recording_id, _duration = ctx._persist_recording( logger=ctx.logger, audio_service=ctx.audio_service, audio_chunks=ctx.audio_chunks, recording_started_at=ctx.recording_started_at, is_24x7=ctx.is_24x7_ref[0], ) await ctx._save_transcription_if_any( audio_service=ctx.audio_service, recording_id=recording_id, text=ctx.transcription_text_ref[0], segment_timestamps=ctx.segment_timestamps_ref[0], ) if recording_id: ctx.logger.info( f"✅ 数据保存成功: recording_id={recording_id}, duration={_duration:.2f}s" ) else: ctx.logger.warning("数据保存完成,但没有生成 recording_id(可能音频为空)") except Exception as e: ctx.logger.error(f"❌ 保存最终数据失败: {e}", exc_info=True) async def _initialize_handlers_internal( *, websocket: WebSocket, logger, transcription_text_ref: list[str], is_connected_ref: list[bool], is_24x7_ref: list[bool], task_set: set[asyncio.Task], on_final_sentence, _parse_init_message, _create_result_callback, _create_error_callback, ) -> tuple: """初始化处理函数和回调""" init_message = await websocket.receive_json() is_24x7 = _parse_init_message(logger, init_message) is_24x7_ref[0] = is_24x7 on_result_base = _create_result_callback( websocket=websocket, logger=logger, transcription_text_ref=transcription_text_ref, is_connected_ref=is_connected_ref, task_set=task_set, ) def on_result(text: str, is_final: bool) -> None: on_result_base(text, is_final) if is_final: on_final_sentence(text) on_error = _create_error_callback( websocket=websocket, logger=logger, is_connected_ref=is_connected_ref, task_set=task_set ) return on_result, on_error, is_24x7 class _StartSegmentMonitorContext: """启动分段监控的上下文,用于减少参数数量""" def __init__(self, **kwargs): self.is_24x7 = kwargs["is_24x7"] self.logger = kwargs["logger"] self.audio_service = kwargs["audio_service"] self.recording_started_at = kwargs["recording_started_at"] self.audio_chunks = kwargs["audio_chunks"] self.transcription_text_ref = kwargs["transcription_text_ref"] self.segment_timestamps_ref = kwargs["segment_timestamps_ref"] self.should_segment_ref = kwargs["should_segment_ref"] self.is_connected_ref = kwargs["is_connected_ref"] self.websocket = kwargs["websocket"] self._get_segment_functions = kwargs["_get_segment_functions"] async def _start_segment_monitor_internal( *, ctx: _StartSegmentMonitorContext ) -> asyncio.Task | None: """启动分段监控任务""" if not ctx.is_24x7: return None _save_current_segment, _segment_monitor_task = ctx._get_segment_functions() return asyncio.create_task( _segment_monitor_task( params={ "logger": ctx.logger, "audio_service": ctx.audio_service, "recording_started_at": ctx.recording_started_at, "audio_chunks": ctx.audio_chunks, "transcription_text_ref": ctx.transcription_text_ref, "segment_timestamps_ref": ctx.segment_timestamps_ref, "should_segment_ref": ctx.should_segment_ref, "is_connected_ref": ctx.is_connected_ref, "websocket": ctx.websocket, }, is_24x7=ctx.is_24x7, ) ) def _setup_websocket_state(): """初始化 WebSocket 状态变量""" recording_started_at = get_utc_now() transcription_text_ref: list[str] = [""] audio_chunks: list[bytes] = [] is_connected_ref: list[bool] = [True] segment_timestamps_ref: list[list[float] | None] = [None] should_segment_ref: list[bool] = [False] is_24x7_ref: list[bool] = [False] data_saved_ref: list[bool] = [False] task_set: set[asyncio.Task] = set() return { "recording_started_at": recording_started_at, "transcription_text_ref": transcription_text_ref, "audio_chunks": audio_chunks, "is_connected_ref": is_connected_ref, "segment_timestamps_ref": segment_timestamps_ref, "should_segment_ref": should_segment_ref, "is_24x7_ref": is_24x7_ref, "data_saved_ref": data_saved_ref, "task_set": task_set, } async def _run_transcription_with_handlers( *, asr_client, websocket: WebSocket, logger, state: dict, on_result, on_error, _run_transcription_stream, ): """运行转录流处理""" ctx = _RunTranscriptionStreamContext( asr_client=asr_client, websocket=websocket, logger=logger, audio_chunks=state["audio_chunks"], segment_timestamps_ref=state["segment_timestamps_ref"], should_segment_ref=state["should_segment_ref"], on_result=on_result, on_error=on_error, ) await _run_transcription_stream(ctx=ctx) async def _setup_websocket_connection(*, websocket: WebSocket, logger) -> dict: """设置 WebSocket 连接并初始化状态""" await websocket.accept() logger.info( f"WebSocket client connected: application_state={websocket.application_state}, client_state={websocket.client_state}" ) return _setup_websocket_state() async def _create_handlers_and_monitor( *, websocket: WebSocket, logger, audio_service, state: dict, funcs: dict, on_final_sentence, ) -> tuple: """创建处理函数并启动监控任务""" on_result, on_error, is_24x7 = await _initialize_handlers_internal( websocket=websocket, logger=logger, transcription_text_ref=state["transcription_text_ref"], is_connected_ref=state["is_connected_ref"], is_24x7_ref=state["is_24x7_ref"], task_set=state["task_set"], on_final_sentence=on_final_sentence, _parse_init_message=funcs["_parse_init_message"], _create_result_callback=funcs["_create_result_callback"], _create_error_callback=funcs["_create_error_callback"], ) segment_ctx = _StartSegmentMonitorContext( is_24x7=is_24x7, logger=logger, audio_service=audio_service, recording_started_at=state["recording_started_at"], audio_chunks=state["audio_chunks"], transcription_text_ref=state["transcription_text_ref"], segment_timestamps_ref=state["segment_timestamps_ref"], should_segment_ref=state["should_segment_ref"], is_connected_ref=state["is_connected_ref"], websocket=websocket, _get_segment_functions=funcs["_get_segment_functions"], ) segment_task = await _start_segment_monitor_internal(ctx=segment_ctx) return on_result, on_error, segment_task async def _run_main_transcription_flow( *, asr_client, websocket: WebSocket, logger, state: dict, funcs: dict, on_final_sentence, _run_transcription_stream, audio_service, ) -> tuple: """运行主要的转录流程""" on_result, on_error, segment_task = await _create_handlers_and_monitor( websocket=websocket, logger=logger, audio_service=audio_service, state=state, funcs=funcs, on_final_sentence=on_final_sentence, ) await _run_transcription_with_handlers( asr_client=asr_client, websocket=websocket, logger=logger, state=state, on_result=on_result, on_error=on_error, _run_transcription_stream=_run_transcription_stream, ) return on_result, on_error, segment_task async def _handle_websocket_errors( *, websocket: WebSocket, logger, e: Exception, _handle_json_error, _handle_websocket_error, ) -> None: """处理 WebSocket 错误""" if isinstance(e, WebSocketDisconnect): logger.info("WebSocket client disconnected,正在保存数据...") elif isinstance(e, json.JSONDecodeError): await _handle_json_error(websocket, logger, e) elif isinstance(e, asyncio.CancelledError): logger.warning("WebSocket handler 被取消,正在保存数据...") elif isinstance(e, KeyboardInterrupt): logger.warning("收到 KeyboardInterrupt,正在保存数据...") else: await _handle_websocket_error(websocket, logger, e) async def _create_nlp_handler( *, websocket: WebSocket, logger, audio_service, state: dict, funcs: dict ) -> tuple: """创建 NLP 处理函数""" _create_realtime_nlp_handler = funcs["_create_realtime_nlp_handler"] return _create_realtime_nlp_handler( websocket=websocket, logger=logger, audio_service=audio_service, is_connected_ref=state["is_connected_ref"], task_set=state["task_set"], throttle_seconds=8.0, ) async def _create_save_final_data_func( *, state: dict, stop_segment_task_func, audio_service, logger, _persist_recording, _save_transcription_if_any, ): """创建保存最终数据的函数""" async def save_final_data(): """保存最终数据(确保只执行一次)""" ctx = _SaveFinalDataContext( data_saved_ref=state["data_saved_ref"], stop_segment_task_func=stop_segment_task_func, audio_chunks=state["audio_chunks"], transcription_text_ref=state["transcription_text_ref"], segment_timestamps_ref=state["segment_timestamps_ref"], recording_started_at=state["recording_started_at"], is_24x7_ref=state["is_24x7_ref"], audio_service=audio_service, logger=logger, _persist_recording=_persist_recording, _save_transcription_if_any=_save_transcription_if_any, ) await _save_final_data_internal(ctx=ctx) return save_final_data async def _cleanup_websocket( *, state: dict, cancel_realtime_nlp, save_final_data, logger, websocket: WebSocket ) -> None: """清理 WebSocket 连接""" state["is_connected_ref"][0] = False cancel_realtime_nlp() try: await save_final_data() except Exception as e: logger.error(f"finally 中保存数据失败: {e}", exc_info=True) logger.info( f"WebSocket handler finished: application_state={websocket.application_state}, client_state={websocket.client_state}" ) async def _handle_transcribe_ws(*, websocket: WebSocket, logger, asr_client, audio_service) -> None: funcs = _get_audio_ws_functions() state = await _setup_websocket_connection(websocket=websocket, logger=logger) segment_task: asyncio.Task | None = None on_final_sentence, cancel_realtime_nlp = await _create_nlp_handler( websocket=websocket, logger=logger, audio_service=audio_service, state=state, funcs=funcs, ) async def stop_segment_task(): """停止分段监控任务""" nonlocal segment_task if segment_task and not segment_task.done(): segment_task.cancel() try: await segment_task except asyncio.CancelledError: logger.info("分段监控任务已取消") save_final_data = await _create_save_final_data_func( state=state, stop_segment_task_func=stop_segment_task, audio_service=audio_service, logger=logger, _persist_recording=funcs["_persist_recording"], _save_transcription_if_any=funcs["_save_transcription_if_any"], ) try: await _run_main_transcription_flow( asr_client=asr_client, websocket=websocket, logger=logger, state=state, funcs=funcs, on_final_sentence=on_final_sentence, _run_transcription_stream=funcs["_run_transcription_stream"], audio_service=audio_service, ) await save_final_data() except Exception as e: await _handle_websocket_errors( websocket=websocket, logger=logger, e=e, _handle_json_error=funcs["_handle_json_error"], _handle_websocket_error=funcs["_handle_websocket_error"], ) finally: await _cleanup_websocket( state=state, cancel_realtime_nlp=cancel_realtime_nlp, save_final_data=save_final_data, logger=logger, websocket=websocket, ) ================================================ FILE: lifetrace/routers/audio_ws_segment.py ================================================ """Audio websocket segment monitoring and saving logic. Split from `audio_ws.py` to reduce file size and complexity. """ from __future__ import annotations import asyncio import importlib from typing import TYPE_CHECKING from starlette.websockets import WebSocketState from lifetrace.util.time_utils import get_utc_now if TYPE_CHECKING: from datetime import datetime # 常量(从 audio_ws 复制以避免循环导入) SILENCE_CHECK_INTERVAL_SECONDS = 60 SILENCE_DETECTION_THRESHOLD_SECONDS = 600 SEGMENT_DURATION_MINUTES = 30 _segment_tasks: set[asyncio.Task] = set() def _track_task(coro) -> asyncio.Task: task = asyncio.create_task(coro) _segment_tasks.add(task) task.add_done_callback(_segment_tasks.discard) return task class _SegmentMonitorContext: """分段监控任务的上下文,用于减少参数数量""" def __init__(self, **kwargs): self.logger = kwargs["logger"] self.audio_service = kwargs["audio_service"] self.recording_started_at = kwargs["recording_started_at"] self.audio_chunks = kwargs["audio_chunks"] self.transcription_text_ref = kwargs["transcription_text_ref"] self.segment_timestamps_ref = kwargs["segment_timestamps_ref"] self.should_segment_ref = kwargs["should_segment_ref"] self.is_connected_ref = kwargs["is_connected_ref"] self.websocket = kwargs.get("websocket") class _SegmentSaveContext: """分段保存的上下文,用于减少参数数量""" def __init__(self, **kwargs): self.logger = kwargs["logger"] self.audio_service = kwargs["audio_service"] self.audio_chunks = kwargs["audio_chunks"] self.transcription_text_ref = kwargs["transcription_text_ref"] self.segment_timestamps_ref = kwargs["segment_timestamps_ref"] self.segment_start_time = kwargs["segment_start_time"] self.websocket = kwargs.get("websocket") self.is_connected_ref = kwargs.get("is_connected_ref") self.segment_reason = kwargs.get("segment_reason") async def _notify_segment_saved(ctx: _SegmentSaveContext) -> None: """通知前端分段已保存""" if not ctx.websocket or not ctx.is_connected_ref or not ctx.is_connected_ref[0]: return try: if ( ctx.websocket.application_state == WebSocketState.CONNECTED and ctx.websocket.client_state == WebSocketState.CONNECTED ): reason_message = ctx.segment_reason or "当前段已保存,开始新段" await ctx.websocket.send_json( { "header": {"name": "SegmentSaved"}, "payload": { "message": reason_message, "segment_start_time": ctx.segment_start_time.isoformat(), }, } ) ctx.logger.info("已通知前端分段保存") except Exception as e: ctx.logger.warning(f"通知前端分段保存失败: {e}") async def _persist_segment_async( *, logger, audio_service, audio_chunks: list[bytes], transcription_text: str, segment_timestamps: list[float] | None, segment_start_time: datetime, ) -> None: """异步保存分段(不阻塞主流程)""" # 延迟导入以避免循环导入 audio_ws_module = importlib.import_module("lifetrace.routers.audio_ws") _persist_recording = audio_ws_module._persist_recording _save_transcription_if_any = audio_ws_module._save_transcription_if_any try: recording_id, _duration = _persist_recording( logger=logger, audio_service=audio_service, audio_chunks=audio_chunks, recording_started_at=segment_start_time, is_24x7=True, ) await _save_transcription_if_any( audio_service=audio_service, recording_id=recording_id, text=transcription_text, segment_timestamps=segment_timestamps, ) logger.info(f"分段保存完成: recording_id={recording_id}, duration={_duration:.2f}s") except Exception as e: logger.error(f"保存分段失败: {e}", exc_info=True) async def _save_current_segment(*, params: dict) -> None: """保存当前段并清空缓冲区""" logger = params["logger"] audio_service = params["audio_service"] audio_chunks = params["audio_chunks"] transcription_text_ref = params["transcription_text_ref"] segment_timestamps_ref = params["segment_timestamps_ref"] segment_start_time = params["segment_start_time"] websocket = params.get("websocket") is_connected_ref = params.get("is_connected_ref") segment_reason = params.get("segment_reason") if not audio_chunks: logger.debug("当前段没有音频数据,跳过保存") return # 保存当前段 current_chunks = audio_chunks.copy() current_text = transcription_text_ref[0] current_timestamps = segment_timestamps_ref[0] # 清空缓冲区,准备新段 audio_chunks.clear() transcription_text_ref[0] = "" segment_timestamps_ref[0] = None # 创建上下文 ctx = _SegmentSaveContext( **{ "logger": logger, "audio_service": audio_service, "audio_chunks": current_chunks, "transcription_text_ref": [current_text], "segment_timestamps_ref": [current_timestamps], "segment_start_time": segment_start_time, "websocket": websocket, "is_connected_ref": is_connected_ref, "segment_reason": segment_reason, } ) # 通知前端分段已保存 await _notify_segment_saved(ctx) # 异步保存当前段(不阻塞) _track_task( _persist_segment_async( logger=ctx.logger, audio_service=ctx.audio_service, audio_chunks=ctx.audio_chunks, transcription_text=ctx.transcription_text_ref[0], segment_timestamps=ctx.segment_timestamps_ref[0], segment_start_time=ctx.segment_start_time, ) ) async def _check_time_segment( ctx: _SegmentMonitorContext, now: datetime, segment_start_time: datetime ) -> bool: """检查30分钟时间分段,返回是否已分段""" elapsed = (now - segment_start_time).total_seconds() if elapsed >= SEGMENT_DURATION_MINUTES * 60: ctx.logger.info("达到30分钟分段时间,保存当前段并开始新段") await _save_current_segment( params={ "logger": ctx.logger, "audio_service": ctx.audio_service, "audio_chunks": ctx.audio_chunks, "transcription_text_ref": ctx.transcription_text_ref, "segment_timestamps_ref": ctx.segment_timestamps_ref, "segment_start_time": segment_start_time, "websocket": ctx.websocket, "is_connected_ref": ctx.is_connected_ref, "segment_reason": "达到30分钟分段时间,保存当前段并开始新段", } ) return True return False async def _check_silence_segment( ctx: _SegmentMonitorContext, now: datetime, segment_start_time: datetime, silence_start_time: datetime | None, ) -> tuple[bool, datetime | None]: """检查静音分段,返回(是否已分段, 新的静音开始时间)""" if len(ctx.audio_chunks) == 0: return False, silence_start_time # 检查最近一段音频是否为静音 # 延迟导入以避免循环导入 audio_ws_module = importlib.import_module("lifetrace.routers.audio_ws") _detect_silence = audio_ws_module._detect_silence recent_chunks = ctx.audio_chunks[-10:] # 检查最近10个chunk recent_audio = b"".join(recent_chunks) is_silent = _detect_silence(recent_audio) if is_silent: if silence_start_time is None: return False, now silence_duration = (now - silence_start_time).total_seconds() if silence_duration >= SILENCE_DETECTION_THRESHOLD_SECONDS: ctx.logger.info(f"检测到长时间静音({silence_duration:.0f}秒),保存当前段并开始新段") await _save_current_segment( params={ "logger": ctx.logger, "audio_service": ctx.audio_service, "audio_chunks": ctx.audio_chunks, "transcription_text_ref": ctx.transcription_text_ref, "segment_timestamps_ref": ctx.segment_timestamps_ref, "segment_start_time": segment_start_time, "websocket": ctx.websocket, "is_connected_ref": ctx.is_connected_ref, "segment_reason": f"检测到长时间静音({silence_duration:.0f}秒),保存当前段并开始新段", } ) return True, None return False, silence_start_time # 有语音,重置静音计时 return False, None async def _check_manual_segment( ctx: _SegmentMonitorContext, now: datetime, segment_start_time: datetime ) -> bool: """检查外部分段请求,返回是否已分段""" _ = now if ctx.should_segment_ref[0]: ctx.logger.info("收到分段请求,保存当前段并开始新段") await _save_current_segment( params={ "logger": ctx.logger, "audio_service": ctx.audio_service, "audio_chunks": ctx.audio_chunks, "transcription_text_ref": ctx.transcription_text_ref, "segment_timestamps_ref": ctx.segment_timestamps_ref, "segment_start_time": segment_start_time, "websocket": ctx.websocket, "is_connected_ref": ctx.is_connected_ref, "segment_reason": "收到分段请求,保存当前段并开始新段", } ) ctx.should_segment_ref[0] = False return True return False async def _segment_monitor_task(*, params: dict, is_24x7: bool) -> None: """监控分段条件:30分钟时间分段 + 静音检测""" _ = is_24x7 logger = params["logger"] recording_started_at = params["recording_started_at"] ctx = _SegmentMonitorContext(**params) segment_start_time = recording_started_at silence_start_time: datetime | None = None while ctx.is_connected_ref[0]: try: await asyncio.sleep(SILENCE_CHECK_INTERVAL_SECONDS) if not ctx.is_connected_ref[0]: break now = get_utc_now() # 检查30分钟时间分段 if await _check_time_segment(ctx, now, segment_start_time): segment_start_time = now silence_start_time = None ctx.recording_started_at = now continue # 检查静音(仅在最近有语音的情况下检查) was_segmented, silence_start_time = await _check_silence_segment( ctx, now, segment_start_time, silence_start_time ) if was_segmented: segment_start_time = now ctx.recording_started_at = now continue # 检查外部分段请求 if await _check_manual_segment(ctx, now, segment_start_time): segment_start_time = now silence_start_time = None ctx.recording_started_at = now except asyncio.CancelledError: logger.info("分段监控任务已取消") break except Exception as e: logger.error(f"分段监控任务错误: {e}", exc_info=True) await asyncio.sleep(5) # 出错后等待5秒再继续 ================================================ FILE: lifetrace/routers/automation.py ================================================ """自动化任务路由""" from fastapi import APIRouter, HTTPException from lifetrace.schemas.automation import ( AutomationTaskCreate, AutomationTaskListResponse, AutomationTaskResponse, AutomationTaskUpdate, ) from lifetrace.services.automation_task_service import AutomationTaskService router = APIRouter(prefix="/api/automation", tags=["automation"]) @router.get("/tasks", response_model=AutomationTaskListResponse) async def list_tasks(): service = AutomationTaskService() tasks = service.list_tasks() return AutomationTaskListResponse( total=len(tasks), tasks=[AutomationTaskResponse(**task) for task in tasks], ) @router.get("/tasks/{task_id}", response_model=AutomationTaskResponse) async def get_task(task_id: int): service = AutomationTaskService() task = service.get_task(task_id) if not task: raise HTTPException(status_code=404, detail="任务不存在") return task @router.post("/tasks", response_model=AutomationTaskResponse) async def create_task(request: AutomationTaskCreate): service = AutomationTaskService() task = service.create_task( name=request.name, description=request.description, enabled=request.enabled, schedule=request.schedule, action=request.action, ) if not task: raise HTTPException(status_code=500, detail="创建任务失败") return task @router.put("/tasks/{task_id}", response_model=AutomationTaskResponse) async def update_task(task_id: int, request: AutomationTaskUpdate): service = AutomationTaskService() task = service.update_task( task_id, name=request.name, description=request.description, enabled=request.enabled, schedule=request.schedule, action=request.action, ) if not task: raise HTTPException(status_code=404, detail="任务不存在") return task @router.delete("/tasks/{task_id}") async def delete_task(task_id: int): service = AutomationTaskService() if not service.delete_task(task_id): raise HTTPException(status_code=404, detail="任务不存在") return {"success": True} @router.post("/tasks/{task_id}/run") async def run_task(task_id: int): service = AutomationTaskService() task = service.get_task(task_id) if not task: raise HTTPException(status_code=404, detail="任务不存在") success = service.run_task(task_id) if not success: raise HTTPException(status_code=400, detail="任务执行失败") return {"success": True} @router.post("/tasks/{task_id}/pause") async def pause_task(task_id: int): service = AutomationTaskService() task = service.update_task( task_id, name=None, description=None, enabled=False, schedule=None, action=None, ) if not task: raise HTTPException(status_code=404, detail="任务不存在") return {"success": True} @router.post("/tasks/{task_id}/resume") async def resume_task(task_id: int): service = AutomationTaskService() task = service.update_task( task_id, name=None, description=None, enabled=True, schedule=None, action=None, ) if not task: raise HTTPException(status_code=404, detail="任务不存在") return {"success": True} ================================================ FILE: lifetrace/routers/chat/__init__.py ================================================ """聊天相关路由聚合包。 此包仅导出统一的 `router`,具体路由实现拆分在多个子模块中: - `core`:基础问答与流式聊天 - `context`:带事件上下文的流式聊天 - `plan`:Plan 问卷与总结相关路由 - `misc`:会话管理、历史记录、查询建议等辅助接口 - `message_todo_extraction`:从消息中提取待办 """ from . import context as _context # noqa: F401 # 导入子模块以注册对应路由(仅用于副作用) from . import core as _core # noqa: F401 from . import message_todo_extraction as _message_todo_extraction # noqa: F401 from . import misc as _misc # noqa: F401 from . import plan as _plan # noqa: F401 from .base import router as router ================================================ FILE: lifetrace/routers/chat/base.py ================================================ """聊天路由基础设施:共享 router 与通用工具函数。""" from typing import Any, TypedDict from fastapi import APIRouter from lifetrace.services.chat_service import ChatService from lifetrace.util.logging_config import get_logger from lifetrace.util.token_usage_logger import log_token_usage logger = get_logger() router = APIRouter(prefix="/api/chat", tags=["chat"]) class StreamMeta(TypedDict, total=False): """统一封装流式聊天的上下文字段,减少函数参数数量。""" session_id: str endpoint: str feature_type: str user_query: str additional_info: dict[str, Any] def _create_llm_stream_generator( *, rag_svc, messages: list[dict[str, str]], temperature: float, chat_service: ChatService, meta: StreamMeta, ): """构造统一的 LLM 流式生成器,并负责保存消息与记录 token 使用量。""" def token_generator(): try: if not rag_svc.llm_client.is_available(): yield "抱歉,LLM服务当前不可用,请稍后重试。" return response = rag_svc.llm_client.client.chat.completions.create( model=rag_svc.llm_client.model, messages=messages, temperature=temperature, stream=True, stream_options={"include_usage": True}, ) total_content = "" usage_info = None for chunk in response: if hasattr(chunk, "usage") and chunk.usage: usage_info = chunk.usage if chunk.choices and len(chunk.choices) > 0 and chunk.choices[0].delta.content: content = chunk.choices[0].delta.content total_content += content yield content if total_content: session_id = meta.get("session_id") if session_id: chat_service.add_message( session_id=session_id, role="assistant", content=total_content, token_count=usage_info.total_tokens if usage_info else None, model=rag_svc.llm_client.model, ) logger.info("[stream] 消息已保存到数据库") if usage_info: session_id = meta.get("session_id") _log_stream_token_usage( rag_svc=rag_svc, usage_info=usage_info, temperature=temperature, total_content=total_content, session_id=session_id, meta=meta, ) except Exception as e: logger.error(f"[stream] 生成失败: {e}") yield "\n[提示] 流式生成出现异常,已结束。" return token_generator() def _log_stream_token_usage( *, rag_svc, usage_info, temperature: float, total_content: str, session_id: str | None, meta: StreamMeta, ) -> None: """记录流式聊天的 token 使用量,抽离成独立函数以降低主流程复杂度。""" try: base_additional_info: dict[str, Any] = { "total_tokens": usage_info.total_tokens, "temperature": temperature, "response_length": len(total_content), } if session_id: base_additional_info["session_id"] = session_id additional_info = meta.get("additional_info") if additional_info: base_additional_info.update(additional_info) endpoint = meta.get("endpoint", "") feature_type = meta.get("feature_type", "") user_query = meta.get("user_query", "") log_token_usage( model=rag_svc.llm_client.model, input_tokens=usage_info.prompt_tokens, output_tokens=usage_info.completion_tokens, endpoint=endpoint, user_query=user_query, response_type="stream", feature_type=feature_type, additional_info=base_additional_info, ) logger.info( f"[stream] Token使用量已记录: input={usage_info.prompt_tokens}, output={usage_info.completion_tokens}" ) except Exception as log_error: logger.error(f"[stream] 记录token使用量失败: {log_error}") ================================================ FILE: lifetrace/routers/chat/context.py ================================================ """带事件上下文的聊天路由。""" from fastapi import Depends, HTTPException from fastapi.responses import StreamingResponse from lifetrace.core.dependencies import get_chat_service, get_rag_service from lifetrace.schemas.chat import ChatMessageWithContext from lifetrace.services.chat_service import ChatService from .base import _create_llm_stream_generator, logger, router @router.post("/stream-with-context") async def chat_with_context_stream( message: ChatMessageWithContext, chat_service: ChatService = Depends(get_chat_service), ): """带事件上下文的流式聊天接口""" try: logger.info( f"[stream-with-context] 收到消息: {message.message}, 上下文事件数: {len(message.event_context or [])}" ) # 1. 确保会话存在(事件助手类型) session_id = _ensure_context_stream_session(message, chat_service) # 2. 基于上下文构建 messages / temperature,并处理 RAG 失败场景 ( messages, temperature, error_response, ) = await _build_context_stream_messages_and_temperature(message, session_id) if error_response is not None: return error_response # 3. 保存用户消息到数据库 chat_service.add_message( session_id=session_id, role="user", content=message.message, ) # 4. 调用统一的 LLM 流式生成器 rag_svc = get_rag_service() token_generator = _create_llm_stream_generator( rag_svc=rag_svc, messages=messages, temperature=temperature, chat_service=chat_service, meta={ "session_id": session_id, "endpoint": "stream_chat_with_context", "feature_type": "event_assistant", "user_query": message.message, "additional_info": { "context_events_count": len(message.event_context or []), }, }, ) # 5. 返回流式响应 headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, # 返回 session_id 供前端使用 } return StreamingResponse( token_generator, media_type="text/plain; charset=utf-8", headers=headers ) except Exception as e: logger.error(f"[stream-with-context] 聊天处理失败: {e}") raise HTTPException(status_code=500, detail="带上下文的流式聊天处理失败") from e def _ensure_context_stream_session( message: ChatMessageWithContext, chat_service: ChatService, ) -> str: """确保带上下文的流式聊天有有效 session,并按事件助手类型创建会话。""" session_id = message.conversation_id or chat_service.generate_session_id() if not message.conversation_id: logger.info(f"[stream-with-context] 创建新会话: {session_id}") chat = chat_service.get_chat_by_session_id(session_id) if not chat: title = message.message[:50] if len(message.message) > 50 else message.message # noqa: PLR2004 chat_service.create_chat( session_id=session_id, chat_type="event", title=title, ) logger.info(f"[stream-with-context] 在数据库中创建会话: {session_id}") return session_id def _build_event_context_text(event_context: list[dict[str, str]] | None) -> str: """根据事件上下文列表构建可读文本。""" if not event_context: return "" context_parts = [] for ctx in event_context: event_text = f"事件ID: {ctx['event_id']}\n{ctx['text']}\n" context_parts.append(event_text) return "\n---\n".join(context_parts) async def _build_context_stream_messages_and_temperature( message: ChatMessageWithContext, session_id: str, ) -> tuple[list[dict[str, str]], float, StreamingResponse | None]: """基于事件上下文构建 messages / temperature,并处理 RAG 失败场景。""" context_text = _build_event_context_text(message.event_context) if context_text: enhanced_message = f"""用户提供了以下事件上下文(来自屏幕记录的OCR文本): ===== 事件上下文开始 ===== {context_text} ===== 事件上下文结束 ===== 用户问题:{message.message} 请基于上述事件上下文回答用户问题。""" else: enhanced_message = message.message rag_service = get_rag_service() rag_result = await rag_service.process_query_stream(enhanced_message, session_id=session_id) if not rag_result.get("success", False): error_msg = rag_result.get( "response", "处理您的查询时出现了错误,请稍后重试。", ) async def error_generator(): yield error_msg return ( [], 0.7, StreamingResponse( error_generator(), media_type="text/plain; charset=utf-8", ), ) messages = rag_result.get("messages", []) temperature = rag_result.get("temperature", 0.7) return messages, temperature, None ================================================ FILE: lifetrace/routers/chat/core.py ================================================ """聊天核心路由:基础问答与流式聊天。""" from fastapi import Depends, HTTPException, Request from fastapi.responses import StreamingResponse from lifetrace.core.dependencies import get_chat_service, get_rag_service from lifetrace.schemas.chat import ChatMessage, ChatResponse from lifetrace.services.chat_service import ChatService from lifetrace.util.language import get_request_language from lifetrace.util.time_utils import get_utc_now from .base import _create_llm_stream_generator, logger, router from .helpers import build_stream_messages_and_temperature, ensure_stream_session from .modes import ( create_agent_streaming_response, create_agno_streaming_response, create_dify_streaming_response, create_web_search_streaming_response, ) @router.post("", response_model=ChatResponse) async def chat_with_llm( message: ChatMessage, chat_service: ChatService = Depends(get_chat_service), ): """与LLM聊天接口 - 集成RAG功能""" _ = chat_service try: logger.info(f"收到聊天消息: {message.message}") # 使用RAG服务处理查询 rag_service = get_rag_service() rag_result = await rag_service.process_query(message.message) if rag_result.get("success", False): success = True # noqa: F841 response = ChatResponse( response=rag_result["response"], timestamp=get_utc_now(), query_info=rag_result.get("query_info"), retrieval_info=rag_result.get("retrieval_info"), performance=rag_result.get("performance"), ) return response else: # 如果RAG处理失败,返回错误信息 error_msg = rag_result.get("response", "处理您的查询时出现了错误,请稍后重试。") return ChatResponse( response=error_msg, timestamp=get_utc_now(), query_info={ "original_query": message.message, "error": rag_result.get("error"), }, ) except Exception as e: logger.error(f"聊天处理失败: {e}") return ChatResponse( response="抱歉,系统暂时无法处理您的请求,请稍后重试。", timestamp=get_utc_now(), query_info={"original_query": message.message, "error": str(e)}, ) @router.post("/stream") async def chat_with_llm_stream( message: ChatMessage, request: Request, chat_service: ChatService = Depends(get_chat_service), ): """与LLM聊天接口(流式输出) 支持额外的 mode 字段: - 默认为现有行为(走本地 LLM + RAG) - 当 mode == \"dify_test\" 时,走 Dify 测试通道 - 当 mode == \"agno\" 时,走 Agno Agent 通道(支持 file/shell 等外部工具) """ try: logger.info(f"[stream] 收到聊天消息: {message.message}") # 解析请求语言 lang = get_request_language(request) # 1. 会话初始化与聊天会话创建 session_id = ensure_stream_session(message, chat_service) # 2. Dify 测试模式(直接返回) if getattr(message, "mode", None) == "dify_test": return create_dify_streaming_response(message, chat_service, session_id) # 2.3. Agent 模式(工具调用框架) if getattr(message, "mode", None) == "agent": return create_agent_streaming_response(message, chat_service, session_id, lang) # 2.5. 联网搜索模式(直接返回,保留向后兼容) if getattr(message, "mode", None) == "web_search": return create_web_search_streaming_response(message, chat_service, session_id, lang) # 2.6. Agno 模式(基于 Agno 框架的 Agent,支持 file/shell 等本地工具) if getattr(message, "mode", None) == "agno": return create_agno_streaming_response(message, chat_service, session_id, lang) # 3. 根据 use_rag 构建 messages / temperature,并处理 RAG 失败场景 ( messages, temperature, user_message_to_save, error_response, ) = await build_stream_messages_and_temperature(message, session_id, lang) if error_response is not None: return error_response # 4. 保存用户原始输入(不含 system prompt) chat_service.add_message( session_id=session_id, role="user", content=user_message_to_save, ) # 5. 调用 LLM,生成统一的流式响应 rag_svc = get_rag_service() token_generator = _create_llm_stream_generator( rag_svc=rag_svc, messages=messages, temperature=temperature, chat_service=chat_service, meta={ "session_id": session_id, "endpoint": "stream_chat", "feature_type": "event_assistant", "user_query": message.message, }, ) headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, # 返回 session_id 供前端使用 } return StreamingResponse( token_generator, media_type="text/plain; charset=utf-8", headers=headers ) except Exception as e: logger.error(f"[stream] 聊天处理失败: {e}") raise HTTPException(status_code=500, detail="流式聊天处理失败") from e ================================================ FILE: lifetrace/routers/chat/helpers.py ================================================ """聊天路由辅助函数:会话管理、消息构建、工作区验证等。""" from pathlib import Path from fastapi.responses import StreamingResponse from lifetrace.core.dependencies import get_rag_service from lifetrace.schemas.chat import ChatMessage from lifetrace.services.chat_service import ChatService from lifetrace.util.language import get_language_instruction from lifetrace.util.logging_config import get_logger logger = get_logger() # ============== 会话管理 ============== def ensure_stream_session(message: ChatMessage, chat_service: ChatService) -> str: """确保流式聊天有有效的 session,并在需要时创建数据库会话。""" session_id = message.conversation_id or chat_service.generate_session_id() if not message.conversation_id: logger.info(f"[stream] 创建新会话: {session_id}") chat = chat_service.get_chat_by_session_id(session_id) if not chat: chat_type = "event" title = message.message[:50] if len(message.message) > 50 else message.message # noqa: PLR2004 chat_service.create_chat( session_id=session_id, chat_type=chat_type, title=title, ) logger.info(f"[stream] 在数据库中创建会话: {session_id}, 类型: {chat_type}") return session_id # ============== 对话历史 ============== def get_conversation_history( chat_service: ChatService, session_id: str, exclude_content: str | None = None ) -> list[dict[str, str]] | None: """获取对话历史(用于 Agno 模式) Args: chat_service: 聊天服务 session_id: 会话 ID exclude_content: 要排除的消息内容(通常是刚添加的用户消息) Returns: 对话历史列表或 None """ try: chat = chat_service.get_chat_by_session_id(session_id) if not chat: return None messages = chat_service.get_messages(session_id) history = [] for msg in messages: role = msg.get("role", "") content = msg.get("content", "") if role in ("user", "assistant") and content != exclude_content: history.append({"role": role, "content": content}) return history if history else None except Exception as e: logger.warning(f"获取对话历史失败: {e},将使用单次对话模式") return None # ============== 工作区验证 ============== # 敏感路径黑名单(禁止作为工作区或其父目录) WORKSPACE_FORBIDDEN_PATHS = { ".git", ".env", ".ssh", "node_modules", "__pycache__", ".venv", "venv", } # 系统目录黑名单 WORKSPACE_SYSTEM_DIRS = {"/", "/usr", "/etc", "/var", "/bin", "/sbin", "/home", "/root"} def validate_workspace_path(workspace_path: str) -> tuple[bool, str]: """验证工作区路径安全性 Args: workspace_path: 工作区路径 Returns: (is_valid, error_message) 元组 """ try: workspace = Path(workspace_path).resolve() except Exception as e: return False, f"无效的路径: {e}" # 检查路径是否存在且是目录 if not workspace.exists(): return False, f"路径不存在: {workspace_path}" if not workspace.is_dir(): return False, f"路径不是目录: {workspace_path}" # 检查是否是系统目录 if str(workspace) in WORKSPACE_SYSTEM_DIRS: return False, "不允许将系统目录作为工作区" # 检查路径中是否包含敏感目录名 for part in workspace.parts: if part in WORKSPACE_FORBIDDEN_PATHS: return False, f"工作区路径包含敏感目录: {part}" return True, "" # ============== 错误响应 ============== def make_error_streaming_response(error_msg: str, session_id: str) -> StreamingResponse: """创建错误流式响应""" def error_gen(): yield error_msg return StreamingResponse( error_gen(), media_type="text/plain; charset=utf-8", headers={"X-Session-Id": session_id}, ) # ============== 消息构建 ============== def build_messages_from_new_schema( message: ChatMessage, user_message_to_save: str, lang: str, ) -> list[dict[str, str]]: """使用新的 schema 字段构建 LLM 消息列表。""" llm_messages = [] if message.system_prompt: system_content = message.system_prompt if message.context: system_content += f"\n\n{message.context}" system_content += get_language_instruction(lang) llm_messages.append({"role": "system", "content": system_content}) elif message.context: system_content = message.context + get_language_instruction(lang) llm_messages.append({"role": "system", "content": system_content}) llm_messages.append({"role": "user", "content": user_message_to_save}) return llm_messages def build_messages_from_legacy_format( full_message: str, lang: str, ) -> tuple[list[dict[str, str]], str]: """从老格式消息解析构建 LLM 消息列表(向后兼容)。 返回 (messages, user_message_to_save)。 """ marker = "用户输入:" if "用户输入:" in full_message else "User input:" if marker not in full_message: return [{"role": "user", "content": full_message}], full_message parts = full_message.split(marker, 1) if len(parts) != 2: # noqa: PLR2004 return [{"role": "user", "content": full_message}], full_message system_prompt = parts[0].strip() + get_language_instruction(lang) user_input = parts[1].strip() messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_input}, ] return messages, user_input async def build_stream_messages_and_temperature( message: ChatMessage, session_id: str, lang: str = "zh", ) -> tuple[list[dict[str, str]], float, str, StreamingResponse | None]: """根据 use_rag / 前端 prompt 构建 messages 与 temperature。 返回 (messages, temperature, user_message_to_save, error_response)。 当 RAG 失败时,error_response 不为 None,调用方应直接返回该响应。 """ user_message_to_save = message.get_user_input_for_storage() if message.use_rag: rag_service = get_rag_service() rag_result = await rag_service.process_query_stream(message.message, session_id, lang=lang) if not rag_result.get("success", False): error_msg = rag_result.get("response", "处理您的查询时出现了错误,请稍后重试。") async def error_generator(): yield error_msg return ( [], 0.7, user_message_to_save, StreamingResponse(error_generator(), media_type="text/plain; charset=utf-8"), ) return ( rag_result.get("messages", []), rag_result.get("temperature", 0.7), user_message_to_save, None, ) # 不使用 RAG:优先新 schema,降级老解析 if message.system_prompt is not None or message.user_input is not None: llm_messages = build_messages_from_new_schema(message, user_message_to_save, lang) return llm_messages, 0.7, user_message_to_save, None messages, user_message_to_save = build_messages_from_legacy_format(message.message, lang) return messages, 0.7, user_message_to_save, None ================================================ FILE: lifetrace/routers/chat/message_todo_extraction.py ================================================ """从消息中提取待办的路由""" import json import re from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: from openai.types.chat import ChatCompletionMessageParam else: ChatCompletionMessageParam = Any from lifetrace.llm.llm_client import LLMClient from lifetrace.routers.chat.base import router from lifetrace.schemas.message_todo_extraction import ( ExtractedMessageTodo, MessageTodoExtractionRequest, MessageTodoExtractionResponse, ) from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt logger = get_logger() def _get_llm_client() -> LLMClient: return LLMClient() @router.post("/extract-todos-from-messages", response_model=MessageTodoExtractionResponse) async def extract_todos_from_messages( request: MessageTodoExtractionRequest, ) -> MessageTodoExtractionResponse: """ 从消息中提取待办事项 Args: request: 包含消息列表、父待办ID和待办上下文的请求 Returns: 提取的待办列表 Raises: HTTPException: 当提取失败时 """ try: llm_client = _get_llm_client() if not llm_client.is_available(): return MessageTodoExtractionResponse( todos=[], error_message="LLM服务当前不可用,请稍后重试", ) # 构建消息文本 messages_text = "\n".join( [f"{msg.get('role', 'unknown')}: {msg.get('content', '')}" for msg in request.messages], ) # 构建待办上下文部分 todo_context_section = "" if request.todo_context: todo_context_section = f"\n**关联待办上下文:**\n{request.todo_context}\n" # 获取提示词 system_prompt = get_prompt("chat_frontend", "message_todo_extraction_system_prompt_zh") user_prompt_template = get_prompt("chat_frontend", "message_todo_extraction_user_prompt_zh") # 填充用户提示词 user_prompt = user_prompt_template.format( messages_text=messages_text, todo_context_section=todo_context_section, ) # 调用 LLM messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] client = llm_client._get_client() response = client.chat.completions.create( model=llm_client.model, messages=cast("list[ChatCompletionMessageParam]", messages), temperature=0.3, ) response_text = response.choices[0].message.content or "" # 解析响应 todos = _parse_llm_response(response_text) return MessageTodoExtractionResponse(todos=todos, error_message=None) except Exception as e: logger.error(f"从消息中提取待办失败: {e}", exc_info=True) return MessageTodoExtractionResponse( todos=[], error_message=f"提取待办失败: {e!s}", ) def _parse_llm_response(response_text: str) -> list[ExtractedMessageTodo]: """ 解析LLM响应为待办事项列表 Args: response_text: LLM返回的文本 Returns: 待办事项列表 """ try: # 尝试提取JSON json_match = re.search(r"\{.*\}", response_text, re.DOTALL) if json_match: json_str = json_match.group(0) result = json.loads(json_str) if "todos" in result and isinstance(result["todos"], list): todos = [] for todo_dict in result["todos"]: if "name" in todo_dict: todos.append( ExtractedMessageTodo( name=todo_dict["name"], description=todo_dict.get("description"), tags=todo_dict.get("tags", []), ), ) return todos else: logger.warning("LLM响应中未找到JSON格式") return [] except json.JSONDecodeError as e: logger.error(f"解析LLM响应JSON失败: {e}\n原始响应: {response_text[:200]}") except Exception as e: logger.error(f"解析待办事项失败: {e}") return [] ================================================ FILE: lifetrace/routers/chat/misc.py ================================================ """聊天相关的辅助/管理路由。""" import importlib from fastapi import Depends, HTTPException, Query from lifetrace.core.dependencies import get_chat_service, get_rag_service from lifetrace.schemas.chat import AddMessageRequest, NewChatRequest, NewChatResponse from lifetrace.services.chat_service import ChatService from lifetrace.util.time_utils import get_utc_now from .base import logger, router @router.post("/new", response_model=NewChatResponse) async def create_new_chat( request: NewChatRequest | None = None, chat_service: ChatService = Depends(get_chat_service), ): """创建新对话会话""" try: # 如果提供了session_id,清除其上下文;否则创建新会话 if request and request.session_id: if chat_service.clear_session_context(request.session_id): session_id = request.session_id message = "会话上下文已清除" else: # 会话不存在,创建新的 session_id = chat_service.create_new_session() message = "创建新对话会话" else: session_id = chat_service.create_new_session() message = "创建新对话会话" logger.info(f"新对话会话: {session_id}") return NewChatResponse(session_id=session_id, message=message, timestamp=get_utc_now()) except Exception as e: logger.error(f"创建新对话失败: {e}") raise HTTPException(status_code=500, detail="创建新对话失败") from e @router.post("/session/{session_id}/message") async def add_message_to_session( session_id: str, request: AddMessageRequest, chat_service: ChatService = Depends(get_chat_service), ): """添加消息到会话(消息已在流式聊天中自动保存,此接口保持兼容性)""" _ = session_id _ = request _ = chat_service try: # 消息在流式聊天接口中已经自动保存,这里只是为了API兼容性 # 如果需要手动保存,可以取消注释以下代码 # chat_service.add_message( # session_id=session_id, # role=request.role, # content=request.content, # ) return { "success": True, "message": "消息已保存", "timestamp": get_utc_now(), } except Exception as e: logger.error(f"保存消息失败: {e}") raise HTTPException(status_code=500, detail="保存消息失败") from e @router.delete("/session/{session_id}") async def clear_chat_session( session_id: str, chat_service: ChatService = Depends(get_chat_service), ): """清除指定会话的上下文""" try: success = chat_service.clear_session_context(session_id) if success: return { "success": True, "message": f"会话 {session_id} 的上下文已清除", "timestamp": get_utc_now(), } else: raise HTTPException(status_code=404, detail="会话不存在") except HTTPException: raise except Exception as e: logger.error(f"清除会话上下文失败: {e}") raise HTTPException(status_code=500, detail="清除会话上下文失败") from e @router.get("/history") async def get_chat_history( session_id: str | None = Query(None), chat_type: str | None = Query(None, description="聊天类型过滤:event, project, general"), chat_service: ChatService = Depends(get_chat_service), ): """获取聊天历史记录(从数据库读取)""" try: return chat_service.get_chat_history(session_id=session_id, chat_type=chat_type) except Exception as e: logger.error(f"获取聊天历史失败: {e}") raise HTTPException(status_code=500, detail="获取聊天历史失败") from e @router.get("/suggestions") async def get_query_suggestions( partial_query: str = Query("", description="部分查询文本"), ): """获取查询建议""" try: suggestions = get_rag_service().get_query_suggestions(partial_query) return {"suggestions": suggestions, "partial_query": partial_query} except Exception as e: logger.error(f"获取查询建议失败: {e}") raise HTTPException(status_code=500, detail="获取查询建议失败") from e @router.get("/query-types") async def get_supported_query_types(): """获取支持的查询类型""" try: return get_rag_service().get_supported_query_types() except Exception as e: logger.error(f"获取查询类型失败: {e}") raise HTTPException(status_code=500, detail="获取查询类型失败") from e @router.get("/agno/tools") async def get_available_agno_tools(): """获取可用的 Agno Agent 工具列表 返回两种类型的工具: 1. FreeTodo 工具:待办管理相关(create_todo, list_todos 等) 2. 外部工具:联网搜索等(duckduckgo 等) """ try: # FreeTodo 工具列表(与 toolkit.py 中的 all_tools 保持同步) agno_module = importlib.import_module("lifetrace.llm.agno_agent") freetodo_tools = [ { "name": "create_todo", "category": "todo", "description": "创建新的待办事项", "description_en": "Create a new todo item", }, { "name": "complete_todo", "category": "todo", "description": "完成待办事项", "description_en": "Mark a todo as completed", }, { "name": "update_todo", "category": "todo", "description": "更新待办事项", "description_en": "Update a todo item", }, { "name": "list_todos", "category": "todo", "description": "列出待办事项", "description_en": "List todo items", }, { "name": "search_todos", "category": "todo", "description": "搜索待办事项", "description_en": "Search todo items", }, { "name": "delete_todo", "category": "todo", "description": "删除待办事项", "description_en": "Delete a todo item", }, { "name": "breakdown_task", "category": "breakdown", "description": "分解复杂任务为子任务", "description_en": "Break down complex tasks into subtasks", }, { "name": "parse_time", "category": "time", "description": "解析自然语言时间表达", "description_en": "Parse natural language time expressions", }, { "name": "check_schedule_conflict", "category": "conflict", "description": "检查时间冲突", "description_en": "Check schedule conflicts", }, { "name": "get_todo_stats", "category": "stats", "description": "获取待办统计信息", "description_en": "Get todo statistics", }, { "name": "get_overdue_todos", "category": "stats", "description": "获取逾期待办", "description_en": "Get overdue todos", }, { "name": "list_tags", "category": "tags", "description": "列出所有标签", "description_en": "List all tags", }, { "name": "get_todos_by_tag", "category": "tags", "description": "按标签获取待办", "description_en": "Get todos by tag", }, { "name": "suggest_tags", "category": "tags", "description": "建议标签", "description_en": "Suggest tags for a todo", }, ] # 外部工具列表 available_external = agno_module.get_available_external_tools() external_tools = [] if "duckduckgo" in available_external: external_tools.append( { "name": "duckduckgo", "category": "search", "description": "DuckDuckGo 联网搜索", "description_en": "DuckDuckGo web search", } ) return { "freetodo_tools": freetodo_tools, "external_tools": external_tools, } except Exception as e: logger.error(f"获取 Agno 工具列表失败: {e}") raise HTTPException(status_code=500, detail="获取 Agno 工具列表失败") from e ================================================ FILE: lifetrace/routers/chat/modes/__init__.py ================================================ """聊天模式处理器模块。""" from .agent import create_agent_streaming_response from .agno import create_agno_streaming_response from .dify import create_dify_streaming_response from .web_search import create_web_search_streaming_response __all__ = [ "create_agent_streaming_response", "create_agno_streaming_response", "create_dify_streaming_response", "create_web_search_streaming_response", ] ================================================ FILE: lifetrace/routers/chat/modes/agent.py ================================================ """Agent 模式处理器(工具调用框架)。""" from fastapi.responses import StreamingResponse from lifetrace.llm.agent_service import AgentService from lifetrace.schemas.chat import ChatMessage from lifetrace.services.chat_service import ChatService from lifetrace.util.logging_config import get_logger logger = get_logger() def create_agent_streaming_response( message: ChatMessage, chat_service: ChatService, session_id: str, lang: str = "zh", ) -> StreamingResponse: """处理 Agent 模式,支持工具调用""" logger.info("[stream] 进入 Agent 模式") # 创建 Agent 服务 agent_service = AgentService() # 获取待办上下文和用户输入 # 优先使用新的 schema 字段,降级到老的解析方式(向后兼容) if message.context is not None or message.user_input is not None: # 新方式:使用 schema 字段 todo_context = message.context user_query = message.get_user_input_for_storage() else: # 老方式:从 message 解析(向后兼容) todo_context = None user_query = message.message if "用户输入:" in message.message or "User input:" in message.message: markers = ["用户输入:", "User input:"] for marker in markers: if marker in message.message: parts = message.message.split(marker, 1) if len(parts) == 2: # noqa: PLR2004 todo_context = parts[0].strip() user_query = parts[1].strip() break # 保存用户消息(只保存用户真正的输入,不含系统提示词和上下文) chat_service.add_message( session_id=session_id, role="user", content=user_query, ) def agent_token_generator(): total_content = "" try: # 流式生成 Agent 回答 for chunk in agent_service.stream_agent_response( user_query=user_query, todo_context=todo_context, lang=lang, ): total_content += chunk yield chunk # 保存完整的助手回复 if total_content: chat_service.add_message( session_id=session_id, role="assistant", content=total_content, ) logger.info("[stream][agent] 消息已保存到数据库") except Exception as e: logger.error(f"[stream][agent] 生成失败: {e}") yield "Agent 处理失败,请检查后端配置。" headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, } return StreamingResponse( agent_token_generator(), media_type="text/plain; charset=utf-8", headers=headers ) ================================================ FILE: lifetrace/routers/chat/modes/agno.py ================================================ """Agno 模式处理器(基于 Agno 框架的 Agent)。""" import json from pathlib import Path from typing import Any from fastapi.responses import StreamingResponse from lifetrace.llm.agno_agent import ( TOOL_EVENT_PREFIX, TOOL_EVENT_SUFFIX, AgnoAgentService, ) from lifetrace.schemas.chat import ChatMessage from lifetrace.services.chat_service import ChatService from lifetrace.util.logging_config import get_logger from ..helpers import ( get_conversation_history, make_error_streaming_response, validate_workspace_path, ) logger = get_logger() def _strip_tool_events( chunk: str, pending: str, ) -> tuple[str, str, list[dict[str, Any]]]: """从流式 chunk 中剥离工具事件标记,返回纯内容、剩余未完成标记、解析出的事件列表。""" content = pending + chunk events: list[dict[str, Any]] = [] while True: start_idx = content.find(TOOL_EVENT_PREFIX) if start_idx == -1: break end_idx = content.find(TOOL_EVENT_SUFFIX, start_idx + len(TOOL_EVENT_PREFIX)) if end_idx == -1: # 工具事件未完整,保留到下次处理 pending_chunk = content[start_idx:] return content[:start_idx], pending_chunk, events json_start = start_idx + len(TOOL_EVENT_PREFIX) json_str = content[json_start:end_idx] try: events.append(json.loads(json_str)) except json.JSONDecodeError: logger.warning("[stream][agno] 工具事件 JSON 解析失败") content = content[:start_idx] + content[end_idx + len(TOOL_EVENT_SUFFIX) :] # 处理可能跨 chunk 的前缀残留(例如 '\n[TOO') max_prefix_len = min(len(TOOL_EVENT_PREFIX) - 1, len(content)) for length in range(max_prefix_len, 0, -1): if TOOL_EVENT_PREFIX.startswith(content[-length:]): return content[:-length], content[-length:], events return content, "", events def _build_external_tools_config( external_tools: list[str], workspace_path: str | None, enable_file_delete: bool, ) -> dict[str, dict]: """构建外部工具配置 Args: external_tools: 外部工具列表 workspace_path: 工作区路径 enable_file_delete: 是否允许删除文件 Returns: 外部工具配置字典 """ config: dict[str, dict] = {} if not workspace_path: return config # 需要 base_dir 的本地工具 if "file" in external_tools: config["file"] = {"base_dir": workspace_path, "enable_delete": enable_file_delete} if "local_fs" in external_tools: config["local_fs"] = {"base_dir": workspace_path} if "shell" in external_tools: config["shell"] = {"base_dir": workspace_path} return config def create_agno_streaming_response( message: ChatMessage, chat_service: ChatService, session_id: str, lang: str = "en", ) -> StreamingResponse: """处理 Agno 模式,使用 Agno 框架的 Agent 进行对话 支持的外部工具: 搜索类(无需配置): - websearch: 通用网页搜索(自动选择后端) - hackernews: Hacker News 新闻 本地类(需要 workspace_path): - file: 文件操作(读写、搜索) - local_fs: 简化文件写入 - shell: 命令行执行 - sleep: 暂停执行 """ logger.info(f"[stream] 进入 Agno 模式, lang={lang}") external_tools = message.external_tools or [] workspace_path = message.workspace_path # 本地类工具需要 workspace_path,如果未提供则使用用户 home 目录 local_tools = {"file", "local_fs", "shell"} needs_workspace = bool(local_tools & set(external_tools)) if needs_workspace and not workspace_path: # 使用用户 home 目录作为默认工作区 workspace_path = str(Path.home()) logger.info(f"[stream][agno] 未指定 workspace_path,使用默认值: {workspace_path}") # 如果提供了 workspace_path,验证其有效性 if workspace_path: is_valid, validation_error = validate_workspace_path(workspace_path) if not is_valid: err = ( f"工作区验证失败: {validation_error}" if lang == "zh" else f"Workspace validation failed: {validation_error}" ) return make_error_streaming_response(err, session_id) # 构建外部工具配置 external_tools_config = _build_external_tools_config( external_tools, workspace_path, message.enable_file_delete ) logger.info( f"[stream][agno] selected_tools={message.selected_tools}, external_tools={external_tools}, " f"workspace_path={workspace_path}" ) # 获取用户真正的输入(用于保存和过滤对话历史) user_input_for_storage = message.get_user_input_for_storage() # 保存用户消息 chat_service.add_message( session_id=session_id, role="user", content=user_input_for_storage, ) # 创建 Agno Agent 服务 agno_service = AgnoAgentService( lang=lang, selected_tools=message.selected_tools, external_tools=external_tools if external_tools else None, external_tools_config=external_tools_config if external_tools_config else None, ) # 获取对话历史 conversation_history = get_conversation_history( chat_service, session_id, user_input_for_storage ) def agno_token_generator(): storage_chunks: list[str] = [] tool_events: list[dict[str, Any]] = [] pending_tool_chunk = "" try: for chunk in agno_service.stream_response( message=message.message, conversation_history=conversation_history, session_id=session_id, ): yield chunk cleaned, pending_tool_chunk, parsed_events = _strip_tool_events( chunk, pending_tool_chunk ) if cleaned: storage_chunks.append(cleaned) if parsed_events: tool_events.extend(parsed_events) # 丢弃未完成的工具事件残片,避免写入历史 storage_content = "".join(storage_chunks).strip() metadata = ( json.dumps({"tool_events": tool_events}, ensure_ascii=False) if tool_events else None ) if storage_content or tool_events: chat_service.add_message( session_id=session_id, role="assistant", content=storage_content, metadata=metadata, ) logger.info("[stream][agno] 消息已保存到数据库") except Exception as e: logger.error(f"[stream][agno] 生成失败: {e}") yield f"Agno Agent 处理失败: {e!s}" headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, } return StreamingResponse( agno_token_generator(), media_type="text/plain; charset=utf-8", headers=headers ) ================================================ FILE: lifetrace/routers/chat/modes/dify.py ================================================ """Dify 测试模式处理器。""" from fastapi.responses import StreamingResponse from lifetrace.schemas.chat import ChatMessage from lifetrace.services.chat_service import ChatService from lifetrace.services.dify_client import call_dify_chat from lifetrace.util.logging_config import get_logger logger = get_logger() def create_dify_streaming_response( message: ChatMessage, chat_service: ChatService, session_id: str, ) -> StreamingResponse: """处理 Dify 测试模式,使用真正的流式输出。 从 message 对象中提取 Dify 相关参数: - dify_response_mode: 响应模式(streaming/blocking),默认 streaming - dify_user: 用户标识,默认 lifetrace-user - dify_inputs: Dify 输入变量字典 - 其他以 dify_ 开头的字段会作为额外参数传递给 Dify API """ logger.info("[stream] 进入 Dify 测试模式") # 保存用户消息(只保存用户真正输入的内容,不含系统提示词和上下文) chat_service.add_message( session_id=session_id, role="user", content=message.get_user_input_for_storage(), ) # 从 message 中提取 Dify 相关参数 message_dict = message.model_dump(exclude={"message", "conversation_id", "use_rag", "mode"}) # 提取 Dify 特定参数 response_mode = message_dict.pop("dify_response_mode", "streaming") user = message_dict.pop("dify_user", None) inputs = message_dict.pop("dify_inputs", None) # 构建额外的 payload 参数(移除 dify_ 前缀) extra_payload = {} for key, value in list(message_dict.items()): if key.startswith("dify_"): # 移除 dify_ 前缀,将剩余的键名作为 payload 参数 payload_key = key[5:] # 移除 "dify_" 前缀 extra_payload[payload_key] = value def dify_token_generator(): total_content = "" try: # 调用 call_dify_chat,传递所有可配置的参数 for chunk in call_dify_chat( message=message.message, user=user, response_mode=response_mode, inputs=inputs, **extra_payload, ): total_content += chunk yield chunk # 保存完整的助手回复 if total_content: chat_service.add_message( session_id=session_id, role="assistant", content=total_content, ) logger.info("[stream][dify] 消息已保存到数据库") except Exception as e: logger.error(f"[stream][dify] 生成失败: {e}") yield "Dify 测试模式调用失败,请检查后端 Dify 配置。" headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, } return StreamingResponse( dify_token_generator(), media_type="text/plain; charset=utf-8", headers=headers ) ================================================ FILE: lifetrace/routers/chat/modes/web_search.py ================================================ """联网搜索模式处理器。""" from fastapi.responses import StreamingResponse from lifetrace.llm.web_search_service import WebSearchService from lifetrace.schemas.chat import ChatMessage from lifetrace.services.chat_service import ChatService from lifetrace.util.logging_config import get_logger logger = get_logger() def create_web_search_streaming_response( message: ChatMessage, chat_service: ChatService, session_id: str, lang: str = "zh", ) -> StreamingResponse: """处理联网搜索模式,使用 Tavily 搜索和 LLM 生成流式输出""" logger.info("[stream] 进入联网搜索模式") # 保存用户消息 chat_service.add_message( session_id=session_id, role="user", content=message.message, ) # 创建联网搜索服务实例 web_search_service = WebSearchService() def web_search_token_generator(): total_content = "" try: # 调用联网搜索服务,流式生成回答 for chunk in web_search_service.stream_answer_with_sources(message.message, lang=lang): total_content += chunk yield chunk # 保存完整的助手回复 if total_content: chat_service.add_message( session_id=session_id, role="assistant", content=total_content, ) logger.info("[stream][web_search] 消息已保存到数据库") except Exception as e: logger.error(f"[stream][web_search] 生成失败: {e}") yield "联网搜索处理失败,请检查后端配置。" headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, } return StreamingResponse( web_search_token_generator(), media_type="text/plain; charset=utf-8", headers=headers ) ================================================ FILE: lifetrace/routers/chat/plan.py ================================================ """Plan 相关聊天路由:任务问卷与任务总结。""" from typing import Any from fastapi import Depends, HTTPException from fastapi.responses import StreamingResponse from lifetrace.core.dependencies import get_chat_service, get_rag_service from lifetrace.schemas.chat import PlanQuestionnaireRequest, PlanSummaryRequest from lifetrace.services.chat_service import ChatService from lifetrace.storage import todo_mgr from lifetrace.util.prompt_loader import get_prompt from .base import _create_llm_stream_generator, logger, router def _format_todo_context(context: dict[str, Any]) -> str: # noqa: C901 """格式化任务上下文信息为易读的文本""" lines: list[str] = [] # 格式化单个任务信息 def _format_todo(todo: dict[str, Any], prefix: str = "") -> str: parts: list[str] = [] parts.append(f"{prefix}- **{todo.get('name', '未知任务')}**") # 包含描述信息(如果存在) description = todo.get("description") if description and description.strip(): parts.append(f"{prefix} 描述: {description}") # 包含用户笔记(如果存在) user_notes = todo.get("user_notes") if user_notes and user_notes.strip(): parts.append(f"{prefix} 用户笔记: {user_notes}") schedule_start = todo.get("start_time") or todo.get("deadline") schedule_end = todo.get("end_time") if schedule_start: schedule_label = schedule_start if schedule_end: schedule_label = f"{schedule_start} ~ {schedule_end}" parts.append(f"{prefix} 时间: {schedule_label}") if todo.get("priority") and todo["priority"] != "none": parts.append(f"{prefix} 优先级: {todo['priority']}") if todo.get("tags"): parts.append(f"{prefix} 标签: {', '.join(todo['tags'])}") if todo.get("status"): parts.append(f"{prefix} 状态: {todo['status']}") return "\n".join(parts) # 当前任务的详细信息(最重要,放在最前面) current = context.get("current") if current: lines.append("**当前任务详细信息:**") lines.append(_format_todo(current)) # 父任务链 parents = context.get("parents", []) if parents: lines.append("\n**父任务链(从直接父任务到根任务):**") for i, parent in enumerate(parents): indent = " " * (len(parents) - i - 1) lines.append(_format_todo(parent, indent)) # 同级任务 siblings = context.get("siblings", []) if siblings: lines.append("\n**同级任务:**") for sibling in siblings: lines.append(_format_todo(sibling, " ")) # 子任务(递归格式化) def _format_children(children: list[dict[str, Any]], depth: int = 0) -> list[str]: result: list[str] = [] for child in children: indent = " " * (depth + 1) result.append(_format_todo(child, indent)) # 递归处理子任务的子任务 if child.get("children"): result.extend(_format_children(child["children"], depth + 1)) return result children = context.get("children", []) if children: lines.append("\n**子任务:**") lines.extend(_format_children(children)) return "\n".join(lines) if lines else "" @router.post("/plan/questionnaire/stream") async def plan_questionnaire_stream( request: PlanQuestionnaireRequest, chat_service: ChatService = Depends(get_chat_service), ): """Plan功能:生成选择题(流式输出)""" try: logger.info( f"[plan/questionnaire] 收到请求,任务名称: {request.todo_name}, todo_id: {request.todo_id}, session_id: {request.session_id}" ) # 1. 确保 plan 会话存在 session_id = _ensure_plan_session( session_id=request.session_id, chat_service=chat_service, todo_name=request.todo_name, context_id=request.todo_id, ) # 2. 构建任务上下文与 prompt messages, context_info = _build_plan_questionnaire_prompts(request) # 3. 保存用户消息到数据库(保存用户请求的任务名称和上下文信息) user_message_content = f"请求为任务生成选择题:{request.todo_name}" if context_info: user_message_content += f"\n\n任务上下文:\n{context_info}" chat_service.add_message( session_id=session_id, role="user", content=user_message_content, ) # 4. 使用统一的 LLM 流式生成器 rag_svc = get_rag_service() token_generator = _create_llm_stream_generator( rag_svc=rag_svc, messages=messages, temperature=0.7, chat_service=chat_service, meta={ "session_id": session_id, "endpoint": "plan_questionnaire_stream", "feature_type": "plan_assistant", "user_query": request.todo_name, "additional_info": { "todo_id": request.todo_id, "has_context": bool(context_info), }, }, ) headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, # 返回 session_id 供前端使用 } return StreamingResponse( token_generator, media_type="text/plain; charset=utf-8", headers=headers ) except Exception as e: logger.error(f"[plan/questionnaire] 处理失败: {e}") raise HTTPException(status_code=500, detail="生成选择题失败") from e def _ensure_plan_session( *, session_id: str | None, chat_service: ChatService, todo_name: str, context_id: int | None = None, ) -> str: """确保 plan 相关会话存在,并在需要时创建。""" final_session_id = session_id or chat_service.generate_session_id() if not session_id: logger.info(f"[plan] 创建新会话: {final_session_id}") chat = chat_service.get_chat_by_session_id(final_session_id) if not chat: chat_service.create_chat( session_id=final_session_id, chat_type="plan", title=f"规划任务: {todo_name}", context_id=context_id, ) logger.info(f"[plan] 在数据库中创建会话: {final_session_id}, 类型: plan") return final_session_id def _build_plan_questionnaire_prompts( request: PlanQuestionnaireRequest, ) -> tuple[list[dict[str, str]], str]: """构建 Plan 问卷的上下文与 prompts。""" context_info = "" if request.todo_id is not None: context = todo_mgr.get_todo_context(request.todo_id) if context: context_info = _format_todo_context(context) current_todo = context.get("current", {}) logger.info( f"[plan/questionnaire] 获取到任务上下文: " f"当前任务(id={current_todo.get('id')}, desc={bool(current_todo.get('description'))}, notes={bool(current_todo.get('user_notes'))}), " f"{len(context.get('parents', []))} 个父任务, " f"{len(context.get('siblings', []))} 个同级任务, {len(context.get('children', []))} 个子任务" ) else: logger.warning(f"[plan/questionnaire] 无法获取 todo_id={request.todo_id} 的上下文") system_prompt = get_prompt("plan_questionnaire", "system_assistant") user_prompt = get_prompt( "plan_questionnaire", "user_prompt", todo_name=request.todo_name, context_info=context_info, ) if not system_prompt or not user_prompt: raise HTTPException(status_code=500, detail="无法加载 prompt 配置,请检查 prompt.yaml") messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] return messages, context_info @router.post("/plan/summary/stream") async def plan_summary_stream( request: PlanSummaryRequest, chat_service: ChatService = Depends(get_chat_service), ): """Plan功能:生成任务总结和子任务(流式输出)""" try: logger.info( f"[plan/summary] 收到请求,任务名称: {request.todo_name}, 回答数量: {len(request.answers)}, session_id: {request.session_id}" ) # 1. 确保 plan 会话存在 session_id = _ensure_plan_session( session_id=request.session_id, chat_service=chat_service, todo_name=request.todo_name, ) # 2. 构建回答文本与 prompt messages, answers_text = _build_plan_summary_prompts(request) # 3. 保存用户消息到数据库(保存用户回答) user_message_content = ( f"为任务生成总结和子任务:{request.todo_name}\n\n用户回答:\n{answers_text}" ) chat_service.add_message( session_id=session_id, role="user", content=user_message_content, ) # 4. 使用统一的 LLM 流式生成器 rag_svc = get_rag_service() token_generator = _create_llm_stream_generator( rag_svc=rag_svc, messages=messages, temperature=0.7, chat_service=chat_service, meta={ "session_id": session_id, "endpoint": "plan_summary_stream", "feature_type": "plan_assistant", "user_query": request.todo_name, "additional_info": { "answers_count": len(request.answers), }, }, ) headers = { "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "X-Session-Id": session_id, # 返回 session_id 供前端使用 } return StreamingResponse( token_generator, media_type="text/plain; charset=utf-8", headers=headers ) except Exception as e: logger.error(f"[plan/summary] 处理失败: {e}") raise HTTPException(status_code=500, detail="生成总结失败") from e def _build_plan_summary_prompts( request: PlanSummaryRequest, ) -> tuple[list[dict[str, str]], str]: """构建 Plan 总结的回答文本与 prompts。""" answers_text = "\n".join( [ f"问题 {question_id}: {', '.join(selected_options)}" for question_id, selected_options in request.answers.items() ] ) answers_text = answers_text.replace("custom:", "") system_prompt = get_prompt("plan_summary", "system_assistant") user_prompt = get_prompt( "plan_summary", "user_prompt", todo_name=request.todo_name, answers_text=answers_text, ) if not system_prompt or not user_prompt: raise HTTPException(status_code=500, detail="无法加载 prompt 配置,请检查 prompt.yaml") messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] return messages, answers_text ================================================ FILE: lifetrace/routers/chat.py ================================================ """聊天相关路由聚合模块。 此模块仅导出统一的 `router`,具体路由实现拆分在 `chat` 子包中: - `chat.core`:基础问答与流式聊天 - `chat.context`:带事件上下文的流式聊天 - `chat.plan`:Plan 问卷与总结相关路由 - `chat.misc`:会话管理、历史记录、查询建议等辅助接口 """ ================================================ FILE: lifetrace/routers/config.py ================================================ """配置相关路由""" import asyncio import json import uuid from typing import Any from fastapi import APIRouter, HTTPException from lifetrace.services.config_service import ConfigService, is_llm_configured from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.settings import settings try: from tavily import TavilyClient except ImportError: TavilyClient = None try: import websockets from websockets.exceptions import ConnectionClosed, InvalidURI except ImportError: websockets = None ConnectionClosed = Exception InvalidURI = Exception logger = get_logger() router = APIRouter(prefix="/api", tags=["config"]) # 初始化配置服务 config_service = ConfigService() # 追踪 LLM 连接是否已验证成功 # 只有通过 API 测试成功后才设置为 True _llm_connection_state: dict[str, bool] = {"verified": False} def verify_llm_connection_on_startup(): """在应用启动时验证现有 LLM 配置 如果配置存在且有效,尝试连接验证 """ if not is_llm_configured(): logger.info("LLM 未配置,跳过启动时验证") return try: from openai import OpenAI # noqa: PLC0415 except Exception as exc: logger.warning(f"OpenAI 依赖未安装,跳过启动时验证: {exc}") return try: api_key = settings.llm.api_key base_url = settings.llm.base_url model = settings.llm.model # 创建临时客户端进行测试 client = OpenAI(api_key=api_key, base_url=base_url) # 发送最小化测试请求验证认证 client.chat.completions.create( model=model, messages=[{"role": "user", "content": "test"}], max_tokens=5 ) _llm_connection_state["verified"] = True logger.info("LLM 启动时连接验证成功") except Exception as e: _llm_connection_state["verified"] = False logger.warning(f"LLM 启动时连接验证失败: {e}") def _validate_aliyun_api_key(llm_key: str) -> dict[str, Any] | None: """验证阿里云 API Key 格式""" min_aliyun_key_length = 20 if not llm_key.startswith("sk-"): return { "success": False, "error": "阿里云 API Key 格式错误,应该以 'sk-' 开头", } if len(llm_key) < min_aliyun_key_length: return { "success": False, "error": f"阿里云 API Key 长度异常(当前: {len(llm_key)} 字符),请检查是否完整", } return None def _handle_llm_test_error(error_msg: str, model: str) -> dict[str, Any]: """处理LLM测试错误,返回友好的错误信息""" if "401" in error_msg or "invalid_api_key" in error_msg: return { "success": False, "error": f"API Key 无效,请检查:\n1. 是否从阿里云控制台正确复制了完整的 API Key\n2. API Key 是否已启用\n3. API Key 是否有权限访问所选模型\n\n原始错误: {error_msg}", } if "404" in error_msg: return { "success": False, "error": f"模型 '{model}' 不存在或无权访问,请检查模型名称是否正确\n\n原始错误: {error_msg}", } return {"success": False, "error": error_msg} def _get_config_value(config_data: dict[str, Any], camel_key: str, snake_key: str) -> Any: """从配置数据中获取值,同时支持 camelCase 和 snake_case 格式 Args: config_data: 配置字典 camel_key: camelCase 格式的键(如 llmApiKey) snake_key: snake_case 格式的键(如 llm_api_key) Returns: 配置值,如果都不存在则返回 None """ return config_data.get(camel_key) or config_data.get(snake_key) @router.post("/test-llm-config") async def test_llm_config(config_data: dict[str, str]): """测试LLM配置是否可用(仅验证认证)""" model = "" try: try: from openai import OpenAI # noqa: PLC0415 except Exception as exc: return {"success": False, "error": f"OpenAI 依赖未安装: {exc}"} # 同时支持 camelCase 和 snake_case 格式(前端 fetcher 会自动转换为 snake_case) llm_key = _get_config_value(config_data, "llmApiKey", "llm_api_key") base_url = _get_config_value(config_data, "llmBaseUrl", "llm_base_url") model = _get_config_value(config_data, "llmModel", "llm_model") if not llm_key or not base_url: return {"success": False, "error": "LLM Key 和 Base URL 不能为空"} # 验证 API Key 格式(针对阿里云) if base_url and "aliyun" in base_url.lower(): validation_error = _validate_aliyun_api_key(llm_key) if validation_error: return validation_error logger.info(f"开始测试 LLM 配置 - 模型: {model}, Key前缀: {llm_key[:10]}...") # 创建临时客户端进行测试 client = OpenAI(api_key=llm_key, base_url=base_url) # 发送最小化测试请求验证认证 try: client.chat.completions.create( model=model, messages=[{"role": "user", "content": "test"}], max_tokens=5 ) logger.info(f"LLM配置测试成功 - 模型: {model}") return {"success": True, "message": "配置验证成功"} except Exception as e: logger.error(f"LLM配置测试失败: {e} - 模型: {model}, Key前缀: {llm_key[:10]}...") return {"success": False, "error": str(e)} except Exception as e: error_msg = str(e) logger.error(f"LLM配置测试失败: {error_msg}") return _handle_llm_test_error(error_msg, model) @router.post("/test-tavily-config") async def test_tavily_config(config_data: dict[str, str]): """测试Tavily配置是否可用(仅验证认证)""" try: if TavilyClient is None: return {"success": False, "error": "Tavily 依赖未安装,请先安装 tavily"} # 同时支持 camelCase 和 snake_case 格式(前端 fetcher 会自动转换为 snake_case) tavily_key = _get_config_value(config_data, "tavilyApiKey", "tavily_api_key") if not tavily_key: return {"success": False, "error": "Tavily API Key 不能为空"} # 检查是否为占位符 invalid_values = [ "xxx", "YOUR_API_KEY_HERE", "YOUR_TAVILY_API_KEY_HERE", ] if tavily_key in invalid_values: return {"success": False, "error": "请填写有效的 Tavily API Key"} logger.info(f"开始测试 Tavily 配置 - Key前缀: {tavily_key[:10]}...") # 创建临时客户端进行测试 try: client = TavilyClient(api_key=tavily_key) # 执行一个简单的搜索请求来验证 API key client.search(query="test", max_results=1) logger.info("Tavily配置测试成功") return {"success": True, "message": "配置验证成功"} except Exception as e: error_msg = str(e) logger.error(f"Tavily配置测试失败: {error_msg} - Key前缀: {tavily_key[:10]}...") # 处理常见的错误 if "401" in error_msg or "unauthorized" in error_msg.lower(): error_msg = ( "API Key 无效,请检查:\n1. 是否从 Tavily 控制台正确复制了完整的 API Key\n" "2. API Key 是否已启用\n\n原始错误: " + error_msg ) return {"success": False, "error": error_msg} except Exception as e: error_msg = str(e) logger.error(f"Tavily配置测试失败: {error_msg}") return {"success": False, "error": error_msg} def _parse_asr_config(config_data: dict[str, Any]) -> dict[str, Any]: """解析 ASR 配置参数""" return { "asr_key": _get_config_value(config_data, "audioAsrApiKey", "audio_asr_api_key"), "base_url": _get_config_value(config_data, "audioAsrBaseUrl", "audio_asr_base_url"), "model": _get_config_value(config_data, "audioAsrModel", "audio_asr_model") or "fun-asr-realtime", "sample_rate": int( _get_config_value(config_data, "audioAsrSampleRate", "audio_asr_sample_rate") or 16000 ), "format_type": _get_config_value(config_data, "audioAsrFormat", "audio_asr_format") or "pcm", "semantic_punc": _get_config_value( config_data, "audioAsrSemanticPunctuationEnabled", "audio_asr_semantic_punctuation_enabled", ) or False, "max_silence": int( _get_config_value( config_data, "audioAsrMaxSentenceSilence", "audio_asr_max_sentence_silence" ) or 1300 ), "heartbeat": _get_config_value(config_data, "audioAsrHeartbeat", "audio_asr_heartbeat") or False, } def _build_asr_run_task_message( task_id: str, model: str, format_type: str, sample_rate: int, semantic_punc: bool, max_silence: int, heartbeat: bool, ) -> dict[str, Any]: """构建 ASR run-task 消息""" return { "header": { "action": "run-task", "task_id": task_id, "streaming": "duplex", }, "payload": { "task_group": "audio", "task": "asr", "function": "recognition", "model": model, "parameters": { "format": format_type, "sample_rate": sample_rate, "semantic_punctuation_enabled": semantic_punc, "max_sentence_silence": max_silence, "heartbeat": heartbeat, }, "input": {}, }, } def _build_asr_finish_task_message(task_id: str) -> dict[str, Any]: """构建 ASR finish-task 消息""" return { "header": { "action": "finish-task", "task_id": task_id, "streaming": "duplex", }, "payload": {"input": {}}, } async def _handle_asr_websocket_response(ws, task_id: str) -> dict[str, Any]: """处理 ASR WebSocket 响应""" try: response = await asyncio.wait_for(ws.recv(), timeout=3.0) data = json.loads(response) event = data.get("header", {}).get("event") logger.info(f"ASR 测试收到响应: {event}") if event in ("task-started", "result-generated"): finish_message = _build_asr_finish_task_message(task_id) await ws.send(json.dumps(finish_message)) logger.info("ASR配置测试成功") return {"success": True, "message": "配置验证成功"} if event == "task-failed": error_code = data.get("header", {}).get("error_code", "") error_message = data.get("header", {}).get("error_message", "") error_msg = f"ASR任务失败: {error_code} - {error_message}" logger.error(f"ASR配置测试失败: {error_msg}") return {"success": False, "error": error_msg} # 其他事件也视为成功(至少连接和认证通过了) logger.info("ASR配置测试成功(收到其他事件)") return {"success": True, "message": "配置验证成功"} except TimeoutError: # 超时也视为成功(至少连接和认证通过了) logger.info("ASR配置测试成功(连接超时但已建立连接)") return {"success": True, "message": "配置验证成功"} async def _test_asr_websocket_connection( base_url: str, asr_key: str, run_task_message: dict[str, Any], task_id: str ) -> dict[str, Any]: """测试 ASR WebSocket 连接""" if websockets is None: return {"success": False, "error": "websockets 依赖未安装,请先安装 websockets"} headers = [("Authorization", f"Bearer {asr_key}")] try: async with websockets.connect(base_url, additional_headers=headers, close_timeout=5) as ws: await ws.send(json.dumps(run_task_message)) logger.info("ASR WebSocket 连接成功,已发送 run-task 消息") return await _handle_asr_websocket_response(ws, task_id) except ConnectionClosed as e: error_msg = f"WebSocket 连接被关闭: {e}" logger.error(f"ASR配置测试失败: {error_msg}") return {"success": False, "error": error_msg} except InvalidURI as e: error_msg = f"WebSocket 地址无效: {e}" logger.error(f"ASR配置测试失败: {error_msg}") return {"success": False, "error": error_msg} except Exception as e: error_msg = str(e) logger.error(f"ASR配置测试失败: {error_msg}") return {"success": False, "error": error_msg} def _handle_asr_test_error(error_msg: str, model: str) -> dict[str, Any]: """处理ASR测试错误,返回友好的错误信息""" if "401" in error_msg or "unauthorized" in error_msg.lower() or "invalid" in error_msg.lower(): return { "success": False, "error": f"API Key 无效,请检查:\n1. 是否从阿里云控制台正确复制了完整的 API Key\n2. API Key 是否已启用\n3. API Key 是否有权限访问 ASR 服务\n\n原始错误: {error_msg}", } if "404" in error_msg or "not found" in error_msg.lower(): return { "success": False, "error": f"WebSocket 地址或模型 '{model}' 不存在,请检查配置是否正确\n\n原始错误: {error_msg}", } if "connection" in error_msg.lower() or "timeout" in error_msg.lower(): return { "success": False, "error": f"连接失败,请检查:\n1. WebSocket 地址是否正确\n2. 网络连接是否正常\n\n原始错误: {error_msg}", } return {"success": False, "error": error_msg} @router.post("/test-asr-config") async def test_asr_config(config_data: dict[str, Any]): """测试ASR配置是否可用(验证WebSocket连接和认证)""" try: # 解析配置参数 config = _parse_asr_config(config_data) asr_key = config["asr_key"] base_url = config["base_url"] model = config["model"] if not asr_key or not base_url: return {"success": False, "error": "ASR API Key 和 Base URL 不能为空"} # 验证 API Key 格式(针对阿里云) if "aliyun" in base_url.lower(): validation_error = _validate_aliyun_api_key(asr_key) if validation_error: return validation_error logger.info(f"开始测试 ASR 配置 - 模型: {model}, Key前缀: {asr_key[:10]}...") # 构建测试消息 task_id = uuid.uuid4().hex[:32] run_task_message = _build_asr_run_task_message( task_id, model, config["format_type"], config["sample_rate"], config["semantic_punc"], config["max_silence"], config["heartbeat"], ) # 测试 WebSocket 连接 return await _test_asr_websocket_connection(base_url, asr_key, run_task_message, task_id) except Exception as e: error_msg = str(e) logger.error(f"ASR配置测试失败: {error_msg}") model = ( _get_config_value(config_data, "audioAsrModel", "audio_asr_model") or "fun-asr-realtime" ) return _handle_asr_test_error(error_msg, model) @router.get("/llm-status") async def get_llm_status(): """检查 LLM 是否已正确配置并通过连接测试 Returns: dict: 包含 configured 字段,表示 LLM 是否已配置且连接验证成功 """ try: # 只有配置存在且连接验证成功才返回 True has_config = is_llm_configured() return {"configured": has_config and _llm_connection_state["verified"]} except Exception as e: logger.error(f"检查 LLM 配置状态失败: {e}") return {"configured": False} @router.get("/get-config") async def get_config_detailed(): """获取当前配置(返回驼峰格式的配置键)""" try: # 使用配置服务获取前端格式的配置 config_dict = config_service.get_config_for_frontend() return { "success": True, "config": config_dict, } except Exception as e: logger.error(f"获取配置失败: {e}") raise HTTPException(status_code=500, detail=f"获取配置失败: {e!s}") from e def _validate_config_fields(config_data: dict[str, str]) -> dict[str, Any] | None: """验证配置字段,返回错误信息或 None""" # 同时支持 camelCase 和 snake_case 格式 llm_key = _get_config_value(config_data, "llmApiKey", "llm_api_key") base_url = _get_config_value(config_data, "llmBaseUrl", "llm_base_url") model = _get_config_value(config_data, "llmModel", "llm_model") # 检查必需字段 missing_fields = [] if not llm_key: missing_fields.append("llmApiKey") if not base_url: missing_fields.append("llmBaseUrl") if not model: missing_fields.append("llmModel") if missing_fields: return { "success": False, "error": f"缺少必需字段: {', '.join(missing_fields)}", } # 验证字段类型和内容 if not isinstance(llm_key, str) or not llm_key.strip(): return {"success": False, "error": "LLM Key必须是非空字符串"} if not isinstance(base_url, str) or not base_url.strip(): return {"success": False, "error": "Base URL必须是非空字符串"} if not isinstance(model, str) or not model.strip(): return {"success": False, "error": "模型名称必须是非空字符串"} return None @router.post("/save-and-init-llm") async def save_and_init_llm(config_data: dict[str, str]): """保存配置并重新初始化LLM服务""" try: # 验证必需字段 validation_error = _validate_config_fields(config_data) if validation_error: return validation_error # 1. 先测试配置 test_result = await test_llm_config(config_data) if not test_result["success"]: # 测试失败,标记连接未验证 _llm_connection_state["verified"] = False return test_result # 2. 保存配置到文件(save_config 内部已经会重载配置并智能判断是否需要重新初始化 LLM) save_result = await save_config(config_data) if not save_result.get("success"): return {"success": False, "error": "保存配置失败"} # 3. 测试成功,标记连接已验证 _llm_connection_state["verified"] = True logger.info("LLM 连接验证成功,配置已保存") return {"success": True, "message": "配置保存成功,正在跳转..."} except Exception as e: error_msg = str(e) logger.error(f"保存并初始化LLM失败: {error_msg}") return {"success": False, "error": error_msg} @router.post("/save-config") async def save_config(settings: dict[str, Any]): """保存配置到config.yaml文件""" try: # 定义更新 LLM 配置状态的回调函数(配置状态已通过 config.is_configured() 实时获取) def update_llm_configured_status(): # 配置状态现在通过 config.is_configured() 实时获取 pass # 调用配置服务保存配置 result = config_service.save_config(settings, update_llm_configured_status) return result except Exception as e: logger.error(f"保存配置失败: {e}") raise HTTPException(status_code=500, detail=f"保存配置失败: {e!s}") from e @router.get("/get-chat-prompts") async def get_chat_prompts(locale: str = "zh"): """获取前端聊天功能所需的 prompt Args: locale: 语言代码,'zh' 或 'en',默认为 'zh' Returns: 包含 editSystemPrompt 和 planSystemPrompt 的字典 """ try: # 根据语言选择对应的 prompt key edit_key = "edit_system_prompt_zh" if locale == "zh" else "edit_system_prompt_en" plan_key = "plan_system_prompt_zh" if locale == "zh" else "plan_system_prompt_en" edit_prompt = get_prompt("chat_frontend", edit_key) plan_prompt = get_prompt("chat_frontend", plan_key) if not edit_prompt or not plan_prompt: logger.warning(f"无法加载 prompt,locale={locale}") raise HTTPException( status_code=500, detail="无法加载 prompt 配置,请检查 prompt.yaml", ) return { "success": True, "editSystemPrompt": edit_prompt, "planSystemPrompt": plan_prompt, } except HTTPException: raise except Exception as e: logger.error(f"获取聊天 prompt 失败: {e}") raise HTTPException( status_code=500, detail=f"获取聊天 prompt 失败: {e!s}", ) from e ================================================ FILE: lifetrace/routers/cost_tracking.py ================================================ """费用统计相关路由""" from datetime import timedelta from fastapi import APIRouter, HTTPException, Query from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now from lifetrace.util.token_usage_logger import get_token_logger router = APIRouter(prefix="/api/cost-tracking", tags=["cost-tracking"]) logger = get_logger() @router.get("/stats") async def get_cost_stats(days: int = Query(30, description="统计天数")): """ 获取费用统计数据 Args: days: 统计最近多少天的数据 """ try: token_logger = get_token_logger() if not token_logger: raise HTTPException(status_code=500, detail="Token日志记录器未初始化") # 获取token使用统计(已包含成本计算) stats = token_logger.get_usage_stats(days=days) # 获取当前模型配置 current_model = settings.llm.model # 获取价格配置 token_logger = get_token_logger() try: input_price, output_price = token_logger._get_model_price(current_model) except Exception: input_price, output_price = 0.0, 0.0 # 整理功能类型费用数据 feature_costs = {} for feature_type, feature_stats in stats.get("feature_stats", {}).items(): feature_costs[feature_type] = { "input_tokens": feature_stats.get("input_tokens", 0), "output_tokens": feature_stats.get("output_tokens", 0), "total_tokens": feature_stats.get("total_tokens", 0), "requests": feature_stats.get("requests", 0), "cost": round(feature_stats.get("total_cost", 0), 4), } # 整理模型费用数据 model_costs = {} for model, model_stats in stats.get("model_stats", {}).items(): model_costs[model] = { "input_tokens": model_stats.get("input_tokens", 0), "output_tokens": model_stats.get("output_tokens", 0), "total_tokens": model_stats.get("total_tokens", 0), "requests": model_stats.get("requests", 0), "input_cost": round(model_stats.get("input_cost", 0), 4), "output_cost": round(model_stats.get("output_cost", 0), 4), "total_cost": round(model_stats.get("total_cost", 0), 4), } # 整理每日费用数据 daily_costs = {} for date_str, daily_stats in stats.get("daily_stats", {}).items(): daily_costs[date_str] = { "input_tokens": daily_stats.get("input_tokens", 0), "output_tokens": daily_stats.get("output_tokens", 0), "total_tokens": daily_stats.get("total_tokens", 0), "requests": daily_stats.get("requests", 0), "cost": round(daily_stats.get("total_cost", 0), 4), } now = get_utc_now().astimezone() return { "success": True, "data": { "total_cost": round(stats.get("total_cost", 0), 4), "total_tokens": stats.get("total_tokens", 0), "total_requests": stats.get("total_requests", 0), "current_model": current_model, "input_token_price": input_price, "output_token_price": output_price, "feature_costs": feature_costs, "model_costs": model_costs, "daily_costs": daily_costs, "start_date": (now - timedelta(days=days)).strftime("%Y-%m-%d"), "end_date": now.strftime("%Y-%m-%d"), }, } except HTTPException: raise except Exception as e: logger.error(f"获取费用统计失败: {e}") raise HTTPException(status_code=500, detail=f"获取费用统计失败: {e!s}") from e @router.get("/config") async def get_cost_config(): """获取费用统计配置""" try: current_model = settings.llm.model token_logger = get_token_logger() try: input_price, output_price = token_logger._get_model_price(current_model) except Exception: input_price, output_price = 0.0, 0.0 return { "success": True, "data": { "model": current_model, "input_token_price": input_price, "output_token_price": output_price, }, } except Exception as e: logger.error(f"获取费用配置失败: {e}") raise HTTPException(status_code=500, detail=f"获取费用配置失败: {e!s}") from e ================================================ FILE: lifetrace/routers/event.py ================================================ """事件相关路由""" from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from lifetrace.core.dependencies import get_event_service from lifetrace.schemas.event import EventDetailResponse, EventListResponse from lifetrace.services.event_service import EventService from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api/events", tags=["event"]) @router.get("", response_model=EventListResponse) async def list_events( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), start_date: str | None = Query(None), end_date: str | None = Query(None), app_name: str | None = Query(None), service: EventService = Depends(get_event_service), ): """获取事件列表(事件=前台应用使用阶段),用于事件级别展示与检索,同时返回总数""" try: start_dt = datetime.fromisoformat(start_date) if start_date else None end_dt = datetime.fromisoformat(end_date) if end_date else None return service.list_events( limit=limit, offset=offset, start_date=start_dt, end_date=end_dt, app_name=app_name, ) except Exception as e: logger.error(f"获取事件列表失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/count") async def count_events( start_date: str | None = Query(None), end_date: str | None = Query(None), app_name: str | None = Query(None), service: EventService = Depends(get_event_service), ): """获取事件总数""" try: start_dt = datetime.fromisoformat(start_date) if start_date else None end_dt = datetime.fromisoformat(end_date) if end_date else None return service.count_events( start_date=start_dt, end_date=end_dt, app_name=app_name, ) except Exception as e: logger.error(f"获取事件总数失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/{event_id}", response_model=EventDetailResponse) async def get_event_detail( event_id: int, service: EventService = Depends(get_event_service), ): """获取事件详情(包含该事件下的截图列表)""" try: return service.get_event_detail(event_id) except HTTPException: raise except Exception as e: logger.error(f"获取事件详情失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/{event_id}/context") async def get_event_context( event_id: int, service: EventService = Depends(get_event_service), ): """获取事件的OCR文本上下文""" try: return service.get_event_context(event_id) except HTTPException: raise except Exception as e: logger.error(f"获取事件上下文失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/{event_id}/generate-summary") async def generate_event_summary( event_id: int, service: EventService = Depends(get_event_service), ): """手动触发单个事件的摘要生成""" try: return service.generate_event_summary(event_id) except HTTPException: raise except Exception as e: logger.error(f"生成事件摘要失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e ================================================ FILE: lifetrace/routers/floating_capture.py ================================================ """悬浮窗截图待办提取路由""" import json import re import time from functools import lru_cache from typing import TYPE_CHECKING, Any, cast from fastapi import APIRouter, HTTPException if TYPE_CHECKING: from openai.types.chat import ChatCompletionMessageParam else: ChatCompletionMessageParam = Any from lifetrace.llm.llm_client import LLMClient from lifetrace.schemas.floating_capture import ( CreatedTodo, ExtractedTodo, FloatingCaptureRequest, FloatingCaptureResponse, ) from lifetrace.storage import todo_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.settings import settings from lifetrace.util.time_parser import calculate_scheduled_time from lifetrace.util.time_utils import get_utc_now logger = get_logger() router = APIRouter(prefix="/api/floating-capture", tags=["floating-capture"]) # 常量定义 MIN_RESPONSE_LENGTH_THRESHOLD = 50 # LLM 响应的最小长度阈值 # LLM 客户端单例 @lru_cache(maxsize=1) def get_llm_client() -> LLMClient: """获取 LLM 客户端单例""" return LLMClient() @router.post("/extract-todos", response_model=FloatingCaptureResponse) async def extract_todos_from_capture(request: FloatingCaptureRequest) -> FloatingCaptureResponse: """ 从悬浮窗截图中提取待办事项 Args: request: 包含 base64 编码截图的请求 Returns: 提取和创建的待办事项列表 """ try: total_start = time.time() logger.info("🚀 开始处理悬浮窗截图请求...") llm_client = get_llm_client() if not llm_client.is_available(): return FloatingCaptureResponse( success=False, message="LLM 服务当前不可用,请检查配置", extracted_todos=[], created_todos=[], created_count=0, ) # 获取已有待办列表用于去重 step_start = time.time() existing_todos = todo_mgr.list_todos(limit=1000, status="active") existing_todos += todo_mgr.list_todos(limit=1000, status="draft") logger.info( f"⏱️ 获取已有待办列表: {time.time() - step_start:.3f}s (共 {len(existing_todos)} 条)" ) # 调用视觉模型提取待办 step_start = time.time() extracted_todos = _call_vision_model_with_base64( llm_client=llm_client, image_base64=request.image_base64, existing_todos=existing_todos, ) vision_time = time.time() - step_start logger.info(f"⏱️ 视觉模型调用总耗时: {vision_time:.3f}s") if not extracted_todos: total_time = time.time() - total_start logger.info(f"✅ 悬浮窗截图处理完成,总耗时: {total_time:.3f}s (未检测到待办事项)") return FloatingCaptureResponse( success=True, message="截图中未检测到待办事项", extracted_todos=[], created_todos=[], created_count=0, ) # 转换为 ExtractedTodo 列表(不计入核心处理时间) conversion_start = time.time() extracted_todo_models = [ ExtractedTodo( title=todo.get("title", ""), description=todo.get("description"), time_info=todo.get("time_info"), source_text=todo.get("source_text"), confidence=todo.get("confidence", 0.5), ) for todo in extracted_todos ] conversion_time = time.time() - conversion_start logger.info(f"⏱️ 数据转换耗时: {conversion_time:.3f}s") # 如果需要创建待办 created_todos: list[CreatedTodo] = [] created_count = 0 if request.create_todos: step_start = time.time() for todo_data in extracted_todos: try: result = _create_draft_todo(todo_data) if result: created_count += 1 created_todos.append( CreatedTodo( id=result["id"], name=result["name"], scheduled_time=result.get("scheduled_time"), ) ) except Exception as e: logger.error(f"创建待办失败: {e}", exc_info=True) continue create_time = time.time() - step_start logger.info(f"⏱️ 创建待办到数据库: {create_time:.3f}s") total_time = time.time() - total_start logger.info( f"✅ 悬浮窗截图处理完成,总耗时: {total_time:.3f}s (提取 {len(extracted_todos)} 个待办,创建 {created_count} 个)" ) return FloatingCaptureResponse( success=True, message=f"成功提取 {len(extracted_todos)} 个待办,创建 {created_count} 个", extracted_todos=extracted_todo_models, created_todos=created_todos, created_count=created_count, ) except Exception as e: logger.error(f"处理悬浮窗截图失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"处理截图失败: {e!s}") from e def _process_llm_response(response: Any, api_time: float) -> str | None: """ 处理 LLM API 响应,提取响应文本 Args: response: LLM API 响应对象 api_time: API 调用耗时 Returns: 响应文本,如果响应无效则返回 None """ # 检查响应结构 if not response or not hasattr(response, "choices") or len(response.choices) == 0: logger.error(f"LLM API 返回异常响应结构: {response}") return None # 检查 Token 使用情况(诊断性能问题) usage = getattr(response, "usage", None) if usage: prompt_tokens = getattr(usage, "prompt_tokens", 0) completion_tokens = getattr(usage, "completion_tokens", 0) total_tokens = getattr(usage, "total_tokens", 0) logger.info( f" 🔢 Token 使用: prompt={prompt_tokens}, completion={completion_tokens}, total={total_tokens}" ) if completion_tokens > 0: tokens_per_second = completion_tokens / api_time if api_time > 0 else 0 logger.info(f" ⚡ 生成速度: {tokens_per_second:.1f} tokens/秒") # 检查是否使用了 thinking 模式 choice = response.choices[0] message = choice.message # 检查是否有 reasoning_content(thinking 模式的输出) reasoning_content = getattr(message, "reasoning_content", None) if reasoning_content: reasoning_len = len(reasoning_content) if reasoning_content else 0 logger.warning(f" 🧠 检测到 Thinking 模式,推理内容长度: {reasoning_len} 字符") # 检查 finish_reason finish_reason = getattr(choice, "finish_reason", None) if finish_reason: logger.info(f" 📋 响应完成原因: {finish_reason}") if finish_reason == "length": logger.warning(" ⚠️ 响应因达到 max_tokens 限制而截断!") response_text = message.content or "" if not response_text: logger.warning("视觉模型返回空响应") return None logger.info(f" 📝 LLM 响应长度: {len(response_text)} 字符") # 诊断:记录响应前100个字符(用于调试) preview = response_text[:100].replace("\n", "\\n") logger.debug(f" 👀 响应预览: {preview}...") return response_text def _call_vision_model_with_base64( llm_client: LLMClient, image_base64: str, existing_todos: list[dict[str, Any]], ) -> list[dict[str, Any]]: """ 使用 base64 图片直接调用视觉模型 Args: llm_client: LLM 客户端 image_base64: Base64 编码的图片 existing_todos: 已有待办列表 Returns: 提取的待办列表 """ try: step_start = time.time() # 格式化已有待办列表为 JSON existing_todos_json = json.dumps( [ { "id": todo.get("id"), "name": todo.get("name"), "description": todo.get("description"), } for todo in existing_todos[:50] # 限制数量 ], ensure_ascii=False, indent=2, ) # 从配置文件加载提示词 system_prompt = get_prompt("auto_todo_detection", "system_assistant") user_prompt = get_prompt( "auto_todo_detection", "user_prompt", existing_todos_json=existing_todos_json, ) # 构建完整的提示词 full_prompt = f"{system_prompt}\n\n{user_prompt}" # 确保 base64 有正确的前缀 if not image_base64.startswith("data:"): image_base64 = f"data:image/png;base64,{image_base64}" # 构建消息内容 content = [ { "type": "image_url", "image_url": {"url": image_base64}, }, {"type": "text", "text": full_prompt}, ] messages = cast("list[ChatCompletionMessageParam]", [{"role": "user", "content": content}]) prep_time = time.time() - step_start logger.info(f" ⏱️ 构建请求准备: {prep_time:.3f}s") # 获取视觉模型配置 vision_model = settings.llm.vision_model or settings.llm.model # 计算图片大小 image_size_kb = len(image_base64) * 3 / 4 / 1024 # Base64 解码后大小估算 logger.info(f"📷 调用视觉模型 {vision_model} (图片大小: {image_size_kb:.1f}KB)") # 调用模型 api_start = time.time() try: client = llm_client._get_client() response = client.chat.completions.create( model=vision_model, messages=messages, temperature=0.3, max_tokens=2000, timeout=60, extra_body={"enable_thinking": False}, # 显式禁用 thinking 模式 ) except Exception as api_error: logger.error(f"LLM API 调用失败: {api_error}", exc_info=True) raise api_time = time.time() - api_start logger.info(f" ⏱️ LLM API 调用耗时: {api_time:.3f}s") # 处理响应 response_text = _process_llm_response(response, api_time) if not response_text: return [] # 解析响应 parse_start = time.time() result = _parse_llm_response(response_text) logger.info(f" ⏱️ 解析响应: {time.time() - parse_start:.3f}s (提取到 {len(result)} 个待办)") if not result and len(response_text) < MIN_RESPONSE_LENGTH_THRESHOLD: logger.warning(f"LLM 响应异常短({len(response_text)} 字符),可能是错误消息或格式问题") return result except Exception as e: logger.error(f"调用视觉模型失败: {e}", exc_info=True) return [] def _parse_llm_response(response_text: str) -> list[dict[str, Any]]: """ 解析 LLM 响应 Args: response_text: LLM 返回的文本 Returns: 待办列表 """ def _extract_todos_from_result(result: dict[str, Any]) -> list[dict[str, Any]]: """从结果中提取待办列表""" if "new_todos" in result: return result["new_todos"] if "todos" in result: return result["todos"] return [] try: # 尝试提取 JSON json_match = re.search(r"\{.*\}", response_text, re.DOTALL) if json_match: json_str = json_match.group(0) result = json.loads(json_str) todos = _extract_todos_from_result(result) if todos: return todos # 如果没有找到 JSON,尝试直接解析 result = json.loads(response_text) todos = _extract_todos_from_result(result) if todos: return todos logger.warning("LLM 响应格式不正确,未找到 new_todos 或 todos 字段") return [] except json.JSONDecodeError as e: logger.error(f"解析 LLM 响应 JSON 失败: {e}") return [] except Exception as e: logger.error(f"解析 LLM 响应失败: {e}", exc_info=True) return [] def _create_draft_todo(todo_data: dict[str, Any]) -> dict[str, Any] | None: """ 创建 draft 状态的待办 Args: todo_data: 待办数据 Returns: 创建结果或 None """ title = todo_data.get("title", "").strip() if not title: return None description = todo_data.get("description") if description: description = description.strip() source_text = todo_data.get("source_text", "") time_info = todo_data.get("time_info", {}) confidence = todo_data.get("confidence") # 计算 scheduled_time scheduled_time = None if time_info: try: reference_time = get_utc_now() scheduled_time = calculate_scheduled_time(time_info, reference_time) except Exception as e: logger.warning(f"计算 scheduled_time 失败: {e}") # 构建 user_notes user_notes_parts = ["来源: 悬浮窗截图"] if source_text: user_notes_parts.append(f"来源文本: {source_text}") if time_info and time_info.get("raw_text"): user_notes_parts.append(f"时间: {time_info.get('raw_text')}") if confidence is not None: user_notes_parts.append(f"置信度: {confidence:.0%}") user_notes = "\n".join(user_notes_parts) # 创建待办 todo_id = todo_mgr.create_todo( name=title, description=description, user_notes=user_notes, start_time=scheduled_time, status="draft", priority="none", tags=["悬浮窗提取"], ) if todo_id: logger.info(f"创建 draft 待办: {todo_id} - {title}") return { "id": todo_id, "name": title, "scheduled_time": scheduled_time.isoformat() if scheduled_time else None, } return None @router.get("/health") async def health_check(): """健康检查""" llm_client = get_llm_client() return { "status": "ok", "llm_available": llm_client.is_available(), } ================================================ FILE: lifetrace/routers/health.py ================================================ """健康检查路由""" import os import shutil import subprocess # nosec B404 from functools import lru_cache from fastapi import APIRouter from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now logger = get_logger() router = APIRouter() # 服务器模式:由命令行参数设置,默认为 "dev" # "dev" = 开发模式(从源码运行或 pnpm dev) # "build" = 打包模式(Electron 打包后运行) _server_state: dict[str, str] = {"mode": "dev"} @lru_cache(maxsize=1) def get_git_commit() -> str: """获取当前 Git Commit(优先读取环境变量,失败时返回 unknown)""" env_commit = os.getenv("LIFETRACE_GIT_COMMIT") or os.getenv("GIT_COMMIT") if env_commit: return env_commit git_path = shutil.which("git") if not git_path: return "unknown" try: return subprocess.check_output( # nosec B603 [git_path, "rev-parse", "HEAD"], stderr=subprocess.DEVNULL, text=True, ).strip() except Exception: return "unknown" def set_server_mode(mode: str) -> None: """设置服务器模式(由 server.py 在启动时调用)""" _server_state["mode"] = mode logger.info(f"服务器模式已设置为: {mode}") def get_server_mode() -> str: """获取当前服务器模式""" return _server_state["mode"] @router.get("/health") async def health_check(): """健康检查""" from lifetrace.core.dependencies import get_ocr_processor # noqa: PLC0415 from lifetrace.storage import db_base # noqa: PLC0415 ocr_processor = get_ocr_processor() return { "app": "lifetrace", # 固定的应用标识,用于前端识别后端服务 "status": "healthy", "server_mode": _server_state["mode"], # 服务器模式:dev 或 build "git_commit": get_git_commit(), "timestamp": get_utc_now(), "database": "connected" if db_base.engine else "disconnected", "ocr": "available" if ocr_processor.is_available() else "unavailable", } @router.get("/health/llm") async def llm_health_check(): """LLM服务健康检查""" try: # 获取RAG服务(延迟加载)- 验证服务能正常初始化 try: from lifetrace.core.dependencies import get_rag_service # noqa: PLC0415 get_rag_service() except Exception as init_error: return { "status": "unavailable", "message": f"RAG服务初始化失败: {init_error!s}", "timestamp": get_utc_now().isoformat(), } # 检查配置是否完整 llm_key = settings.llm.api_key base_url = settings.llm.base_url if not llm_key or not base_url: return { "status": "unconfigured", "message": "LLM配置不完整,请设置API Key和Base URL", "timestamp": get_utc_now().isoformat(), } try: from openai import OpenAI # noqa: PLC0415 except Exception as exc: logger.error(f"OpenAI 依赖未安装: {exc}") return { "status": "error", "message": f"OpenAI 依赖未安装: {exc}", "timestamp": get_utc_now().isoformat(), } client = OpenAI(api_key=llm_key, base_url=base_url) model = settings.llm.model # 发送最小化测试请求 response = client.chat.completions.create( # noqa: F841 model=model, messages=[{"role": "user", "content": "test"}], max_tokens=5, timeout=10, ) return { "status": "healthy", "message": "LLM服务正常", "model": model, "timestamp": get_utc_now().isoformat(), } except Exception as e: logger.error(f"LLM健康检查失败: {e}") return { "status": "error", "message": f"LLM服务异常: {e!s}", "timestamp": get_utc_now().isoformat(), } ================================================ FILE: lifetrace/routers/journal.py ================================================ """日记相关路由""" from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Path, Query from lifetrace.core.dependencies import get_journal_service from lifetrace.schemas.journal import ( JournalAutoLinkRequest, JournalAutoLinkResponse, JournalCreate, JournalGenerateRequest, JournalGenerateResponse, JournalListResponse, JournalResponse, JournalUpdate, ) from lifetrace.services.journal_service import JournalService from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(tags=["journals"]) @router.post("/api/journals", response_model=JournalResponse, status_code=201) async def create_journal( journal: JournalCreate, service: JournalService = Depends(get_journal_service), ): """创建日记""" try: return service.create_journal(journal) except HTTPException: raise except Exception as e: logger.error(f"创建日记失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"创建日记失败: {e!s}") from e @router.get("/api/journals", response_model=JournalListResponse) async def list_journals( limit: int = Query(100, ge=1, le=1000, description="返回数量限制"), offset: int = Query(0, ge=0, description="偏移量"), start_date: datetime | None = Query(None, description="开始日期筛选"), end_date: datetime | None = Query(None, description="结束日期筛选"), service: JournalService = Depends(get_journal_service), ): """获取日记列表""" try: return service.list_journals(limit, offset, start_date, end_date) except Exception as e: logger.error(f"获取日记列表失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取日记列表失败: {e!s}") from e @router.get("/api/journals/{journal_id}", response_model=JournalResponse) async def get_journal( journal_id: int = Path(..., description="日记ID"), service: JournalService = Depends(get_journal_service), ): """获取日记详情""" try: return service.get_journal(journal_id) except HTTPException: raise except Exception as e: logger.error(f"获取日记详情失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取日记详情失败: {e!s}") from e @router.put("/api/journals/{journal_id}", response_model=JournalResponse) async def update_journal( journal_id: int = Path(..., description="日记ID"), journal: JournalUpdate | None = None, service: JournalService = Depends(get_journal_service), ): """更新日记""" try: if journal is None: raise HTTPException(status_code=400, detail="缺少日记更新内容") return service.update_journal(journal_id, journal) except HTTPException: raise except Exception as e: logger.error(f"更新日记失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"更新日记失败: {e!s}") from e @router.delete("/api/journals/{journal_id}", status_code=204) async def delete_journal( journal_id: int = Path(..., description="日记ID"), service: JournalService = Depends(get_journal_service), ): """删除日记""" try: service.delete_journal(journal_id) return None except HTTPException: raise except Exception as e: logger.error(f"删除日记失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"删除日记失败: {e!s}") from e @router.post("/api/journals/auto-link", response_model=JournalAutoLinkResponse) async def auto_link_journal( payload: JournalAutoLinkRequest, service: JournalService = Depends(get_journal_service), ): """自动关联 Todo/活动""" try: return service.auto_link(payload) except HTTPException: raise except Exception as e: logger.error(f"自动关联失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"自动关联失败: {e!s}") from e @router.post("/api/journals/generate-objective", response_model=JournalGenerateResponse) async def generate_objective_journal( payload: JournalGenerateRequest, service: JournalService = Depends(get_journal_service), ): """生成客观记录""" try: return service.generate_objective(payload) except HTTPException: raise except Exception as e: logger.error(f"生成客观记录失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"生成客观记录失败: {e!s}") from e @router.post("/api/journals/generate-ai", response_model=JournalGenerateResponse) async def generate_ai_journal( payload: JournalGenerateRequest, service: JournalService = Depends(get_journal_service), ): """生成 AI 视角记录""" try: return service.generate_ai_view(payload) except HTTPException: raise except Exception as e: logger.error(f"生成 AI 视角失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"生成 AI 视角失败: {e!s}") from e ================================================ FILE: lifetrace/routers/logs.py ================================================ """日志相关路由""" from fastapi import APIRouter, HTTPException, Query from fastapi.responses import PlainTextResponse from lifetrace.util.base_paths import get_user_logs_dir from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api/logs", tags=["logs"]) # 常量定义 BYTES_PER_KB = 1024 # 字节到KB的转换因子 MAX_LOG_LINES = 1000 # 返回日志的最大行数 @router.get("/files") async def get_log_files(): """获取日志文件列表""" try: # 使用配置中的日志目录 logs_dir = get_user_logs_dir() if not logs_dir.exists(): return [] log_files = [] # 递归扫描所有子目录中的.log文件 for file_path in logs_dir.rglob("*.log"): # 获取相对于logs目录的路径 relative_path = file_path.relative_to(logs_dir) # 获取文件大小 file_size = file_path.stat().st_size size_str = ( f"{file_size // BYTES_PER_KB}KB" if file_size > BYTES_PER_KB else f"{file_size}B" ) log_files.append( { "name": str(relative_path), "path": str(file_path), "size": size_str, "category": relative_path.parent.name if relative_path.parent.name != "." else "root", } ) return sorted(log_files, key=lambda x: x["name"]) except Exception as e: logger.error(f"获取日志文件列表失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/content", response_class=PlainTextResponse) async def get_log_content(file: str = Query(..., description="日志文件相对路径")): """获取日志文件内容""" try: # 使用配置中的日志目录 logs_dir = get_user_logs_dir() log_file = logs_dir / file # 安全检查:确保文件在logs目录内 if not str(log_file.resolve()).startswith(str(logs_dir.resolve())): raise HTTPException(status_code=400, detail="无效的文件路径") if not log_file.exists(): raise HTTPException(status_code=404, detail="日志文件不存在") # 读取文件内容 with open(log_file, encoding="utf-8") as f: lines = f.readlines() # 只返回最后 MAX_LOG_LINES 行,避免内存问题 if len(lines) > MAX_LOG_LINES: lines = lines[-MAX_LOG_LINES:] return "".join(lines) except HTTPException: raise except Exception as e: logger.error(f"读取日志文件失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e ================================================ FILE: lifetrace/routers/notification.py ================================================ """通知相关路由""" from fastapi import APIRouter, HTTPException from lifetrace.storage.notification_storage import clear_notification, get_notifications from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api/notifications", tags=["notifications"]) @router.get("") async def get_notification(): """ 获取通知列表(按时间倒序) 返回格式: [ { "id": "通知ID", "title": "通知标题", "content": "通知内容", "timestamp": "时间戳(ISO格式)", "todo_id": 待办ID(可选) } ] """ try: notifications = get_notifications() if notifications: logger.debug("返回通知列表: %s", len(notifications)) return notifications except Exception as e: logger.error(f"获取通知失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"获取通知失败: {e!s}") from e @router.delete("/{notification_id}") async def delete_notification(notification_id: str): """ 删除指定通知 Args: notification_id: 通知ID Returns: {"success": True, "message": "通知已删除"} """ try: deleted = clear_notification(notification_id) if deleted: logger.info(f"删除通知: {notification_id}") return {"success": True, "message": "通知已删除"} logger.warning(f"通知不存在,无法删除: {notification_id}") return {"success": False, "message": "通知不存在"} except Exception as e: logger.error(f"删除通知失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"删除通知失败: {e!s}") from e ================================================ FILE: lifetrace/routers/ocr.py ================================================ """OCR相关路由""" from fastapi import APIRouter, HTTPException from lifetrace.core.dependencies import get_ocr_processor from lifetrace.storage import ocr_mgr, screenshot_mgr from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api/ocr", tags=["ocr"]) @router.post("/process") async def process_ocr(screenshot_id: int): """手动触发OCR处理""" ocr_processor = get_ocr_processor() if not ocr_processor.is_available(): raise HTTPException(status_code=503, detail="OCR服务不可用") screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id) if not screenshot: raise HTTPException(status_code=404, detail="截图不存在") if screenshot["is_processed"]: raise HTTPException(status_code=400, detail="截图已经处理过") try: # 执行OCR处理 ocr_result = ocr_processor.process_image(screenshot["file_path"]) if ocr_result["success"]: # 保存OCR结果 ocr_mgr.add_ocr_result( screenshot_id=screenshot["id"], text_content=ocr_result["text_content"], confidence=ocr_result["confidence"], language=ocr_result.get("language", "ch"), processing_time=ocr_result["processing_time"], ) return { "success": True, "text_content": ocr_result["text_content"], "confidence": ocr_result["confidence"], "processing_time": ocr_result["processing_time"], } else: raise HTTPException(status_code=500, detail=ocr_result["error"]) except Exception as e: logger.error(f"OCR处理失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/statistics") async def get_ocr_statistics(): """获取OCR处理统计""" ocr_processor = get_ocr_processor() return ocr_processor.get_statistics() ================================================ FILE: lifetrace/routers/proactive_ocr.py ================================================ """Proactive OCR 路由""" import sys from fastapi import APIRouter, HTTPException from lifetrace.jobs.proactive_ocr.service import get_proactive_ocr_service from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api/proactive-ocr", tags=["proactive-ocr"]) @router.post("/start") async def start_proactive_ocr(): """启动主动OCR监控服务""" try: service = get_proactive_ocr_service() service.start() return { "success": True, "message": "Proactive OCR service started", "status": service.get_status(), } except Exception as e: logger.error(f"Failed to start proactive OCR: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to start service: {e!s}") from e @router.post("/stop") async def stop_proactive_ocr(): """停止主动OCR监控服务""" try: service = get_proactive_ocr_service() service.stop() return { "success": True, "message": "Proactive OCR service stopped", "status": service.get_status(), } except Exception as e: logger.error(f"Failed to stop proactive OCR: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to stop service: {e!s}") from e @router.post("/capture") async def capture_once(): """手动触发一次捕获和OCR处理""" try: service = get_proactive_ocr_service() result = service.run_once() if result is None: return { "success": False, "message": "No target window detected or capture failed", } return { "success": True, "message": "Capture and OCR completed", "result": result, } except Exception as e: logger.error(f"Failed to capture: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Capture failed: {e!s}") from e @router.get("/status") async def get_proactive_ocr_status(): """获取主动OCR服务状态""" try: service = get_proactive_ocr_service() status = service.get_status() return { "success": True, "status": status, } except Exception as e: logger.error(f"Failed to get status: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to get status: {e!s}") from e @router.get("/health") async def health_check(): """健康检查""" service = get_proactive_ocr_service() status = service.get_status() return { "status": "ok", "platform": sys.platform, "windows_available": sys.platform == "win32", "service_running": status["is_running"], } ================================================ FILE: lifetrace/routers/rag.py ================================================ """RAG服务和应用图标相关路由""" from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse from lifetrace.core.dependencies import get_rag_service from lifetrace.util.app_utils import get_icon_filename from lifetrace.util.base_paths import get_app_root from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() router = APIRouter(prefix="/api", tags=["rag"]) @router.get("/rag/health") async def rag_health_check(): """RAG服务健康检查""" try: return get_rag_service().health_check() except Exception as e: logger.error(f"RAG健康检查失败: {e}") return { "rag_service": "error", "error": str(e), "timestamp": get_utc_now().isoformat(), } @router.get("/app-icon/{app_name}") async def get_app_icon(app_name: str): """ 获取应用图标 根据映射表返回对应的图标文件 Args: app_name: 应用名称 Returns: 图标文件 """ try: # 根据映射表获取图标文件名 icon_filename = get_icon_filename(app_name) if not icon_filename: raise HTTPException(status_code=404, detail="图标未找到") # 构建图标文件路径 # 获取项目根目录(lifetrace 的父目录) lifetrace_dir = get_app_root() project_root = lifetrace_dir.parent icon_path = project_root / ".github" / "assets" / "icons" / "apps" / icon_filename if not icon_path.exists(): logger.warning(f"图标文件不存在: {icon_path}") raise HTTPException(status_code=404, detail="图标文件不存在") # 返回图标文件 return FileResponse( str(icon_path), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"}, # 缓存1天 ) except HTTPException: raise except Exception as e: logger.error(f"获取应用图标失败 {app_name}: {e}") raise HTTPException(status_code=500, detail=f"获取图标失败: {e!s}") from e ================================================ FILE: lifetrace/routers/scheduler.py ================================================ """ 定时任务管理路由 提供定时任务的查询、管理和控制接口 """ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from lifetrace.jobs.scheduler import get_scheduler_manager from lifetrace.services.config_service import ConfigService from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import reload_settings logger = get_logger() router = APIRouter(prefix="/api/scheduler", tags=["scheduler"]) # 数据模型 class JobInfo(BaseModel): """任务信息""" id: str name: str | None = None func: str trigger: str next_run_time: str | None = None pending: bool = False class JobListResponse(BaseModel): """任务列表响应""" total: int jobs: list[JobInfo] class JobOperationRequest(BaseModel): """任务操作请求""" job_id: str class JobIntervalUpdateRequest(BaseModel): """任务间隔更新请求""" job_id: str seconds: int | None = None minutes: int | None = None hours: int | None = None class JobOperationResponse(BaseModel): """任务操作响应""" success: bool message: str @router.get("/jobs", response_model=JobListResponse) async def get_all_jobs(): """获取所有定时任务""" try: scheduler_manager = get_scheduler_manager() jobs = scheduler_manager.get_all_jobs() job_list = [] for job in jobs: job_info = JobInfo( id=job.id, name=job.name, func=str(job.func_ref), trigger=str(job.trigger), next_run_time=(job.next_run_time.isoformat() if job.next_run_time else None), pending=job.next_run_time is not None, ) job_list.append(job_info) return JobListResponse(total=len(job_list), jobs=job_list) except Exception as e: logger.error(f"获取任务列表失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/jobs/{job_id}", response_model=JobInfo) async def get_job_detail(job_id: str): """获取指定任务的详细信息""" try: scheduler_manager = get_scheduler_manager() job = scheduler_manager.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="任务不存在") return JobInfo( id=job.id, name=job.name, func=str(job.func_ref), trigger=str(job.trigger), next_run_time=(job.next_run_time.isoformat() if job.next_run_time else None), pending=job.next_run_time is not None, ) except HTTPException: raise except Exception as e: logger.error(f"获取任务详情失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/jobs/{job_id}/pause", response_model=JobOperationResponse) async def pause_job(job_id: str): """暂停指定任务""" try: scheduler_manager = get_scheduler_manager() success = scheduler_manager.pause_job(job_id) if success: # 同步更新配置文件中的 enabled 状态 _sync_job_enabled_to_config(job_id, False) return JobOperationResponse(success=True, message=f"任务 {job_id} 已暂停") else: raise HTTPException(status_code=400, detail="暂停任务失败") except HTTPException: raise except Exception as e: logger.error(f"暂停任务失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/jobs/{job_id}/resume", response_model=JobOperationResponse) async def resume_job(job_id: str): """恢复指定任务""" try: scheduler_manager = get_scheduler_manager() success = scheduler_manager.resume_job(job_id) if success: # 同步更新配置文件中的 enabled 状态 _sync_job_enabled_to_config(job_id, True) return JobOperationResponse(success=True, message=f"任务 {job_id} 已恢复") else: raise HTTPException(status_code=400, detail="恢复任务失败") except HTTPException: raise except Exception as e: logger.error(f"恢复任务失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.put("/jobs/{job_id}/interval", response_model=JobOperationResponse) async def update_job_interval(job_id: str, request: JobIntervalUpdateRequest): """更新任务执行间隔""" try: scheduler_manager = get_scheduler_manager() # 验证至少提供一个时间参数 if request.seconds is None and request.minutes is None and request.hours is None: raise HTTPException(status_code=400, detail="必须提供至少一个时间间隔参数") success = scheduler_manager.modify_job_interval( job_id, seconds=request.seconds, minutes=request.minutes, hours=request.hours, ) if success: # 同步更新配置文件中的间隔 _sync_job_interval_to_config(job_id, request.seconds, request.minutes, request.hours) interval_parts = [] if request.hours: interval_parts.append(f"{request.hours}小时") if request.minutes: interval_parts.append(f"{request.minutes}分钟") if request.seconds: interval_parts.append(f"{request.seconds}秒") interval_str = "".join(interval_parts) return JobOperationResponse( success=True, message=f"任务 {job_id} 的执行间隔已更新为 {interval_str}", ) else: raise HTTPException(status_code=400, detail="更新任务间隔失败") except HTTPException: raise except Exception as e: logger.error(f"更新任务间隔失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.delete("/jobs/{job_id}", response_model=JobOperationResponse) async def remove_job(job_id: str): """删除指定任务""" try: scheduler_manager = get_scheduler_manager() success = scheduler_manager.remove_job(job_id) if success: return JobOperationResponse(success=True, message=f"任务 {job_id} 已删除") else: raise HTTPException(status_code=400, detail="删除任务失败") except HTTPException: raise except Exception as e: logger.error(f"删除任务失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/status") async def get_scheduler_status(): """获取调度器状态""" try: scheduler_manager = get_scheduler_manager() jobs = scheduler_manager.get_all_jobs() running_jobs = [job for job in jobs if job.next_run_time is not None] paused_jobs = [job for job in jobs if job.next_run_time is None] return { "running": scheduler_manager.scheduler.running if scheduler_manager.scheduler else False, "total_jobs": len(jobs), "running_jobs": len(running_jobs), "paused_jobs": len(paused_jobs), } except Exception as e: logger.error(f"获取调度器状态失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/jobs/pause-all", response_model=JobOperationResponse) async def pause_all_jobs(): """暂停所有任务""" try: scheduler_manager = get_scheduler_manager() # 获取所有任务列表 jobs = scheduler_manager.get_all_jobs() paused_jobs = [] # 逐个暂停任务并同步配置 for job in jobs: if job.next_run_time is not None: # 只暂停未暂停的任务 try: scheduler_manager.pause_job(job.id) # 同步更新配置文件 _sync_job_enabled_to_config(job.id, False) paused_jobs.append(job.id) except Exception as e: logger.error(f"暂停任务 {job.id} 失败: {e}") return JobOperationResponse( success=True, message=f"已暂停 {len(paused_jobs)} 个任务", ) except Exception as e: logger.error(f"批量暂停任务失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/jobs/resume-all", response_model=JobOperationResponse) async def resume_all_jobs(): """恢复所有任务""" try: scheduler_manager = get_scheduler_manager() # 获取所有任务列表 jobs = scheduler_manager.get_all_jobs() resumed_jobs = [] # 逐个恢复任务并同步配置 for job in jobs: if job.next_run_time is None: # 只恢复已暂停的任务 try: scheduler_manager.resume_job(job.id) # 同步更新配置文件 _sync_job_enabled_to_config(job.id, True) resumed_jobs.append(job.id) except Exception as e: logger.error(f"恢复任务 {job.id} 失败: {e}") return JobOperationResponse( success=True, message=f"已恢复 {len(resumed_jobs)} 个任务", ) except Exception as e: logger.error(f"批量恢复任务失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e def _sync_job_enabled_to_config(job_id: str, enabled: bool): """同步任务的启用状态到配置文件(持久化到 YAML 文件) Args: job_id: 任务ID enabled: 是否启用 """ # 定义任务ID到配置路径的映射 job_config_map = { "recorder_job": "jobs.recorder.enabled", "ocr_job": "jobs.ocr.enabled", "clean_data_job": "jobs.clean_data.enabled", "activity_aggregator_job": "jobs.activity_aggregator.enabled", "todo_recorder_job": "jobs.todo_recorder.enabled", "proactive_ocr_job": "jobs.proactive_ocr.enabled", } # 联动配置:todo_recorder_job 与 auto_todo_detection 联动 linked_config_map = { "todo_recorder_job": "jobs.auto_todo_detection.enabled", } if job_id in job_config_map: config_key = job_config_map[job_id] try: # 使用 ConfigService 持久化配置到文件 config_service = ConfigService() config_updates = {config_key: enabled} # 如果存在联动配置,同步更新 if job_id in linked_config_map: linked_key = linked_config_map[job_id] config_updates[linked_key] = enabled logger.info(f"联动更新配置: {linked_key} = {enabled}") config_service.update_config_file(config_updates, config_service._config_path) # 重新加载配置到内存 reload_settings() logger.info(f"已同步任务 {job_id} 的启用状态到配置文件: {enabled}") except Exception as e: logger.error(f"同步任务启用状态到配置失败: {e}") def _sync_job_interval_to_config( job_id: str, seconds: int | None = None, minutes: int | None = None, hours: int | None = None ): """同步任务的执行间隔到配置文件(持久化到 YAML 文件) Args: job_id: 任务ID seconds: 秒数 minutes: 分钟数 hours: 小时数 """ # 定义任务ID到配置路径的映射 job_config_map = { "recorder_job": "jobs.recorder.interval", "ocr_job": "jobs.ocr.interval", "clean_data_job": "jobs.clean_data.interval", "activity_aggregator_job": "jobs.activity_aggregator.interval", "todo_recorder_job": "jobs.todo_recorder.interval", "proactive_ocr_job": "jobs.proactive_ocr.interval", } if job_id in job_config_map: config_key = job_config_map[job_id] try: # 计算总间隔秒数 total_seconds = 0 if seconds: total_seconds += seconds if minutes: total_seconds += minutes * 60 if hours: total_seconds += hours * 3600 # 使用 ConfigService 持久化配置到文件 config_service = ConfigService() config_service.update_config_file( {config_key: total_seconds}, config_service._config_path ) # 重新加载配置到内存 reload_settings() logger.info(f"已同步任务 {job_id} 的执行间隔到配置文件: {total_seconds}秒") except Exception as e: logger.error(f"同步任务执行间隔到配置失败: {e}") ================================================ FILE: lifetrace/routers/screenshot.py ================================================ """截图相关路由""" import os from datetime import datetime from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse from lifetrace.schemas.screenshot import ScreenshotResponse from lifetrace.storage import get_session, screenshot_mgr from lifetrace.storage.models import OCRResult from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api/screenshots", tags=["screenshot"]) @router.get("", response_model=list[ScreenshotResponse]) async def get_screenshots( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), start_date: str | None = Query(None), end_date: str | None = Query(None), app_name: str | None = Query(None), ): """获取截图列表""" try: # 解析日期 start_dt = None end_dt = None if start_date: start_dt = datetime.fromisoformat(start_date) if end_date: end_dt = datetime.fromisoformat(end_date) # 搜索截图 - 直接传递offset和limit给数据库查询 results = screenshot_mgr.search_screenshots( start_date=start_dt, end_date=end_dt, app_name=app_name, limit=limit, offset=offset, # 新增offset参数 ) return [ScreenshotResponse(**result) for result in results] except Exception as e: logger.error(f"获取截图列表失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/{screenshot_id}") async def get_screenshot(screenshot_id: int): """获取单个截图详情""" screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id) if not screenshot: raise HTTPException(status_code=404, detail="截图不存在") # 获取OCR结果 ocr_data = None try: with get_session() as session: ocr_result = session.query(OCRResult).filter_by(screenshot_id=screenshot_id).first() # 在session内提取数据 if ocr_result: ocr_data = { "text_content": ocr_result.text_content, "confidence": ocr_result.confidence, "language": ocr_result.language, "processing_time": ocr_result.processing_time, } except Exception as e: logger.warning(f"获取OCR结果失败: {e}") # screenshot已经是字典格式,直接使用 result = screenshot.copy() result["ocr_result"] = ocr_data return result @router.get("/{screenshot_id}/image") async def get_screenshot_image(screenshot_id: int): """获取截图图片文件""" try: screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id) if not screenshot: raise HTTPException(status_code=404, detail="截图不存在") # 检查文件是否已被清理 if screenshot.get("file_deleted", False): logger.debug(f"截图文件已被清理: screenshot_id={screenshot_id}") raise HTTPException(status_code=410, detail="文件已被清理") file_path = screenshot["file_path"] # 检查文件是否存在 if not os.path.exists(file_path): logger.warning(f"截图文件不存在: screenshot_id={screenshot_id}, path={file_path}") raise HTTPException(status_code=404, detail="图片文件不存在") return FileResponse( file_path, media_type="image/png", filename=f"screenshot_{screenshot_id}.png", ) except HTTPException: raise except Exception as e: logger.error(f"获取截图图像时发生错误: {e}") raise HTTPException(status_code=500, detail="服务器内部错误") from e @router.get("/{screenshot_id}/path") async def get_screenshot_path(screenshot_id: int): """获取截图文件路径""" screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id) if not screenshot: raise HTTPException(status_code=404, detail="截图不存在") file_path = screenshot["file_path"] if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="图片文件不存在") return {"screenshot_id": screenshot_id, "file_path": file_path, "exists": True} ================================================ FILE: lifetrace/routers/search.py ================================================ """搜索相关路由""" from fastapi import APIRouter, HTTPException from lifetrace.schemas.event import EventResponse from lifetrace.schemas.screenshot import ScreenshotResponse from lifetrace.schemas.search import SearchRequest from lifetrace.storage import event_mgr, screenshot_mgr from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api", tags=["search"]) @router.post("/search", response_model=list[ScreenshotResponse]) async def search_screenshots(search_request: SearchRequest): """搜索截图""" try: results = screenshot_mgr.search_screenshots( query=search_request.query, start_date=search_request.start_date, end_date=search_request.end_date, app_name=search_request.app_name, limit=search_request.limit, ) return [ScreenshotResponse(**result) for result in results] except Exception as e: logger.error(f"搜索截图失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/event-search", response_model=list[EventResponse]) async def search_events(search_request: SearchRequest): """事件级简单文本搜索:按OCR分组后返回事件摘要""" try: results = event_mgr.search_events_simple( query=search_request.query, start_date=search_request.start_date, end_date=search_request.end_date, app_name=search_request.app_name, limit=search_request.limit, ) return [EventResponse(**r) for r in results] except Exception as e: logger.error(f"搜索事件失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e ================================================ FILE: lifetrace/routers/system.py ================================================ """系统资源相关路由""" import psutil from fastapi import APIRouter, HTTPException, Query from lifetrace.core.module_registry import get_capabilities_report from lifetrace.schemas.stats import StatisticsResponse from lifetrace.schemas.system import ( CapabilitiesResponse, ProcessInfo, SystemResourcesResponse, ) from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_database_path, get_screenshots_dir from lifetrace.util.time_utils import get_utc_now logger = get_logger() router = APIRouter(prefix="/api", tags=["system"]) # LifeTrace 相关进程关键字 LIFETRACE_KEYWORDS = [ "lifetrace", "lifetrace.recorder", "lifetrace.processor", "lifetrace.ocr", "lifetrace.jobs.recorder", "lifetrace.jobs.processor", "lifetrace.jobs.ocr", "recorder.py", "processor.py", "ocr.py", "server.py", "start_all_services.py", ] # 单位转换常量 BYTES_PER_MB = 1024 * 1024 BYTES_PER_GB = 1024**3 def _get_lifetrace_processes() -> tuple[list[ProcessInfo], float, float]: """获取 LifeTrace 相关进程信息 Returns: tuple: (进程列表, 总内存MB, 总CPU百分比) """ processes = [] total_memory = 0.0 total_cpu = 0.0 for proc in psutil.process_iter(["pid", "name", "cmdline", "memory_info"]): try: cmdline = " ".join(proc.info["cmdline"]) if proc.info["cmdline"] else "" if any(keyword in cmdline.lower() for keyword in LIFETRACE_KEYWORDS): try: cpu_percent = proc.cpu_percent(interval=None) except Exception: cpu_percent = 0.0 memory_mb = proc.info["memory_info"].rss / BYTES_PER_MB memory_vms_mb = proc.info["memory_info"].vms / BYTES_PER_MB process_info = ProcessInfo( pid=proc.info["pid"], name=proc.info["name"], cmdline=cmdline, memory_mb=memory_mb, memory_vms_mb=memory_vms_mb, cpu_percent=cpu_percent, ) processes.append(process_info) total_memory += memory_mb total_cpu += cpu_percent except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): continue return processes, total_memory, total_cpu def _get_disk_usage() -> dict: """获取磁盘使用信息""" disk_usage = {} for partition in psutil.disk_partitions(): try: usage = psutil.disk_usage(partition.mountpoint) disk_usage[partition.device] = { "total_gb": usage.total / BYTES_PER_GB, "used_gb": usage.used / BYTES_PER_GB, "free_gb": usage.free / BYTES_PER_GB, "percent": (usage.used / usage.total) * 100, } except PermissionError: continue return disk_usage def _get_storage_info() -> dict: """获取数据库和截图存储信息""" db_path = get_database_path() db_size_mb = db_path.stat().st_size / BYTES_PER_MB if db_path.exists() else 0 screenshots_path = get_screenshots_dir() screenshots_size_mb = 0.0 screenshots_count = 0 if screenshots_path.exists(): for file_path in screenshots_path.glob("*.png"): if file_path.is_file(): screenshots_size_mb += file_path.stat().st_size / BYTES_PER_MB screenshots_count += 1 return { "database_mb": db_size_mb, "screenshots_mb": screenshots_size_mb, "screenshots_count": screenshots_count, "total_mb": db_size_mb + screenshots_size_mb, } @router.get("/statistics", response_model=StatisticsResponse) async def get_statistics(): """获取系统统计信息""" from lifetrace.storage import stats_mgr # noqa: PLC0415 stats = stats_mgr.get_statistics() return StatisticsResponse(**stats) @router.post("/cleanup") async def cleanup_old_data(days: int = Query(30, ge=1)): """清理旧数据""" try: from lifetrace.storage import stats_mgr # noqa: PLC0415 stats_mgr.cleanup_old_data(days) return {"success": True, "message": f"清理了 {days} 天前的数据"} except Exception as e: logger.error(f"清理数据失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/system-resources", response_model=SystemResourcesResponse) async def get_system_resources(): """获取系统资源使用情况""" try: # 获取 LifeTrace 相关进程 lifetrace_processes, total_memory, total_cpu = _get_lifetrace_processes() # 获取系统资源信息 memory = psutil.virtual_memory() cpu_percent = psutil.cpu_percent(interval=None) cpu_count = psutil.cpu_count() # 获取磁盘和存储信息 disk_usage = _get_disk_usage() storage_info = _get_storage_info() return SystemResourcesResponse( memory={ "total_gb": memory.total / BYTES_PER_GB, "available_gb": memory.available / BYTES_PER_GB, "used_gb": (memory.total - memory.available) / BYTES_PER_GB, "percent": memory.percent, }, cpu={"percent": cpu_percent, "count": cpu_count}, disk=disk_usage, lifetrace_processes=lifetrace_processes, storage=storage_info, summary={ "total_memory_mb": total_memory, "total_cpu_percent": total_cpu, "process_count": len(lifetrace_processes), "total_storage_mb": storage_info["total_mb"], }, timestamp=get_utc_now(), ) except Exception as e: logger.error(f"获取系统资源信息失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/capabilities", response_model=CapabilitiesResponse) async def get_capabilities(): """获取后端模块能力状态""" return get_capabilities_report() ================================================ FILE: lifetrace/routers/time_allocation.py ================================================ """时间分配相关路由""" from datetime import UTC, datetime from fastapi import APIRouter, HTTPException, Query from lifetrace.schemas.stats import TimeAllocationResponse from lifetrace.storage import event_mgr from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api", tags=["time-allocation"]) # 应用分类关键词映射 _APP_CATEGORY_KEYWORDS: dict[str, list[str]] = { "social": [ "qq", "wechat", "weixin", "微信", "telegram", "discord", "slack", "dingtalk", "钉钉", "wxwork", "企业微信", "feishu", "飞书", "lark", "whatsapp", "line", "skype", "zoom", "teams", "腾讯会议", ], "browser": [ "chrome", "msedge", "edge", "firefox", "browser", "浏览器", "safari", "opera", "brave", ], "development": [ "code", "vscode", "visual studio code", "pycharm", "idea", "intellij", "webstorm", "editor", "开发工具", "sublime", "atom", "vim", "neovim", "github desktop", "git", "github", "gitkraken", "sourcetree", ], "file_management": [ "explorer", "文件", "file", "finder", "nautilus", "dolphin", "thunar", ], "office": [ "word", "excel", "powerpoint", "wps", "libreoffice", "office", "onenote", "outlook", ], } def _categorize_app(app_name: str) -> str: """应用分类逻辑(优先匹配社交类应用)""" if not app_name: return "other" app_lower = app_name.lower().strip() # 按优先级顺序检查各分类 for category, keywords in _APP_CATEGORY_KEYWORDS.items(): if any(keyword in app_lower for keyword in keywords): return category return "other" def _build_daily_distribution(hourly_usage: dict[int, dict[str, float]]) -> list[dict]: """构建24小时分布数据""" daily_distribution = [] for hour in range(24): hour_data: dict = {"hour": hour, "apps": {}} if hour in hourly_usage: for app_name, duration in hourly_usage[hour].items(): hour_data["apps"][app_name] = int(duration) daily_distribution.append(hour_data) return daily_distribution def _build_app_details(app_usage_summary: dict[str, dict]) -> list[dict]: """构建应用详情列表""" app_details = [ { "app_name": app_name, "total_time": int(app_data.get("total_time", 0)), "category": _categorize_app(app_name), } for app_name, app_data in app_usage_summary.items() ] app_details.sort(key=lambda x: x["total_time"], reverse=True) return app_details @router.get("/time-allocation", response_model=TimeAllocationResponse) async def get_time_allocation( start_date: str | None = Query(None, description="开始日期, YYYY-MM-DD 格式"), end_date: str | None = Query(None, description="结束日期, YYYY-MM-DD 格式"), days: int = Query(None, description="统计天数 (弃用, 仅用于兼容)", ge=1, le=365), ): """获取时间分配数据(支持日期区间或天数)""" try: if start_date and end_date: start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=UTC) end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC) stats_data = event_mgr.get_app_usage_stats(start_date=start_dt, end_date=end_dt) else: use_days = days if days else 7 stats_data = event_mgr.get_app_usage_stats(days=use_days) total_time = int(stats_data.get("total_time", 0)) daily_distribution = _build_daily_distribution(stats_data.get("hourly_usage", {})) app_details = _build_app_details(stats_data.get("app_usage_summary", {})) return TimeAllocationResponse( total_time=total_time, daily_distribution=daily_distribution, app_details=app_details ) except Exception as e: logger.error(f"获取时间分配数据失败: {e}") raise HTTPException(status_code=500, detail=f"获取时间分配数据失败: {e!s}") from e ================================================ FILE: lifetrace/routers/todo.py ================================================ """Todo 管理路由 - 使用依赖注入""" from __future__ import annotations import hashlib import os from pathlib import Path as FsPath from typing import TYPE_CHECKING from uuid import uuid4 from fastapi import APIRouter, Depends, File, HTTPException, Path, Query, Response, UploadFile from fastapi.responses import FileResponse from lifetrace.core.dependencies import get_todo_service from lifetrace.schemas.todo import ( TodoAttachmentResponse, TodoCreate, TodoListResponse, TodoReorderRequest, TodoResponse, TodoUpdate, ) from lifetrace.services.icalendar_service import ICalendarService from lifetrace.util.path_utils import get_attachments_dir if TYPE_CHECKING: from lifetrace.services.todo_service import TodoService router = APIRouter(prefix="/api/todos", tags=["todos"]) MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024 # 50MB def _sanitize_filename(name: str) -> str: return FsPath(name).name if name else "attachment" @router.get("", response_model=TodoListResponse) async def list_todos( limit: int = Query(200, ge=1, le=2000, description="返回数量限制"), offset: int = Query(0, ge=0, description="偏移量"), status: str | None = Query(None, description="状态筛选:active/completed/canceled"), service: TodoService = Depends(get_todo_service), ): """获取待办列表""" return service.list_todos(limit, offset, status) @router.get("/{todo_id}", response_model=TodoResponse) async def get_todo( todo_id: int = Path(..., description="Todo ID"), service: TodoService = Depends(get_todo_service), ): """获取单个待办""" return service.get_todo(todo_id) @router.post( "/{todo_id}/attachments", response_model=list[TodoAttachmentResponse], status_code=201, ) async def upload_attachments( todo_id: int = Path(..., description="Todo ID"), files: list[UploadFile] = File(..., description="附件列表"), service: TodoService = Depends(get_todo_service), ): """上传附件并绑定到 Todo""" if not files: raise HTTPException(status_code=400, detail="未提供附件") attachments_dir = get_attachments_dir() attachments_dir.mkdir(parents=True, exist_ok=True) created = [] for file in files: if not file.filename: continue content = await file.read() if not content: raise HTTPException(status_code=400, detail="附件内容为空") size = len(content) if size > MAX_ATTACHMENT_SIZE: raise HTTPException(status_code=413, detail="附件超过 50MB 限制") file_name = _sanitize_filename(file.filename) ext = FsPath(file_name).suffix storage_name = f"{uuid4().hex}{ext}" target_path = attachments_dir / storage_name target_path.write_bytes(content) file_hash = hashlib.sha256(content).hexdigest() created.append( service.add_attachment( todo_id=todo_id, file_name=file_name, file_path=str(target_path), file_size=size, mime_type=file.content_type, file_hash=file_hash, ) ) return created @router.delete("/{todo_id}/attachments/{attachment_id}", status_code=204) async def delete_attachment( todo_id: int = Path(..., description="Todo ID"), attachment_id: int = Path(..., description="附件 ID"), service: TodoService = Depends(get_todo_service), ): """解绑附件(不删除实际文件)""" service.remove_attachment(todo_id=todo_id, attachment_id=attachment_id) @router.get("/attachments/{attachment_id}/file") async def get_attachment_file( attachment_id: int = Path(..., description="附件 ID"), service: TodoService = Depends(get_todo_service), ): """下载附件文件""" attachment = service.get_attachment(attachment_id) file_path = attachment["file_path"] if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="附件文件不存在") return FileResponse( file_path, media_type=attachment.get("mime_type") or "application/octet-stream", filename=attachment.get("file_name") or f"attachment-{attachment_id}", ) @router.post("", response_model=TodoResponse, status_code=201) async def create_todo( todo: TodoCreate, service: TodoService = Depends(get_todo_service), ): """创建待办""" return service.create_todo(todo) @router.put("/{todo_id}", response_model=TodoResponse) async def update_todo( todo_id: int = Path(..., description="Todo ID"), todo: TodoUpdate | None = None, service: TodoService = Depends(get_todo_service), ): """更新待办""" if todo is None: raise HTTPException(status_code=400, detail="缺少待办更新内容") return service.update_todo(todo_id, todo) @router.delete("/{todo_id}", status_code=204) async def delete_todo( todo_id: int = Path(..., description="Todo ID"), service: TodoService = Depends(get_todo_service), ): """删除待办""" service.delete_todo(todo_id) @router.post("/reorder", status_code=200) async def reorder_todos( request: TodoReorderRequest, service: TodoService = Depends(get_todo_service), ): """批量更新待办的排序和父子关系""" items = [ { "id": item.id, "order": item.order, **({"parent_todo_id": item.parent_todo_id} if item.parent_todo_id is not None else {}), } for item in request.items ] return service.reorder_todos(items) @router.get("/export/ics") async def export_ics( limit: int = Query(2000, ge=1, le=2000, description="导出数量限制"), offset: int = Query(0, ge=0, description="导出偏移量"), status: str | None = Query(None, description="状态筛选:active/completed/canceled"), service: TodoService = Depends(get_todo_service), ): """导出 Todo 为 ICS 文件""" payload = service.list_todos(limit, offset, status) todos = [t.model_dump() if hasattr(t, "model_dump") else t for t in payload.get("todos", [])] ics_content = ICalendarService().export_todos(todos) filename = "lifetrace-todos.ics" if not status else f"lifetrace-todos-{status}.ics" return Response( content=ics_content, media_type="text/calendar; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/import/ics", response_model=list[TodoResponse]) async def import_ics( file: UploadFile = File(...), service: TodoService = Depends(get_todo_service), ): """从 ICS 文件导入 Todo""" if not file.filename: raise HTTPException(status_code=400, detail="未提供 ICS 文件") content = await file.read() if not content: raise HTTPException(status_code=400, detail="ICS 文件为空") try: ics_text = content.decode("utf-8") except UnicodeDecodeError: ics_text = content.decode("utf-8", errors="ignore") todos = ICalendarService().import_todos(ics_text) created: list[TodoResponse] = [] seen_uids: set[str] = set() for todo in todos: uid = (todo.uid or "").strip() if uid: if uid in seen_uids: continue seen_uids.add(uid) if service.get_todo_by_uid(uid): continue created.append(service.create_todo(todo)) return created ================================================ FILE: lifetrace/routers/todo_extraction.py ================================================ """待办提取相关路由""" from fastapi import APIRouter, HTTPException from lifetrace.llm.todo_extraction_service import todo_extraction_service from lifetrace.schemas.todo_extraction import ( ExtractedTodo, TodoExtractionRequest, TodoExtractionResponse, TodoTimeInfo, ) from lifetrace.storage import event_mgr from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() router = APIRouter(prefix="/api/todo-extraction", tags=["todo-extraction"]) @router.post("/extract", response_model=TodoExtractionResponse) async def extract_todos_from_event(request: TodoExtractionRequest): """ 从事件中提取待办事项 针对白名单应用(微信、飞书等)的事件,使用多模态大模型分析截图, 提取用户承诺的待办事项,特别是带时间信息的待办。 Args: request: 待办提取请求,包含事件ID和可选的截图采样比例 Returns: 待办提取响应,包含提取的待办列表和元信息 Raises: HTTPException: 当请求参数无效或提取失败时 """ try: event_id = request.event_id # 验证事件是否存在 event_info = event_mgr.get_event_summary(event_id) if not event_info: raise HTTPException(status_code=404, detail=f"事件 {event_id} 不存在") app_name = event_info.get("app_name") logger.info(f"开始提取事件 {event_id} 的待办事项,应用: {app_name}") # 调用待办提取服务 result = todo_extraction_service.extract_todos_from_event( event_id=event_id, screenshot_sample_ratio=request.screenshot_sample_ratio, ) # 检查是否有错误 if result.get("error_message"): error_msg = result["error_message"] # 如果是白名单检查失败,返回400;其他错误返回500 if "不在待办提取白名单中" in error_msg: raise HTTPException(status_code=400, detail=error_msg) else: logger.warning(f"待办提取返回错误: {error_msg}") # 仍然返回结果,但包含错误信息 # 构建响应 todos = [] for todo_dict in result.get("todos", []): try: # 构建时间信息 time_info_dict = todo_dict.get("time_info", {}) time_info = TodoTimeInfo(**time_info_dict) # 构建待办项 todo = ExtractedTodo( title=todo_dict.get("title", ""), description=todo_dict.get("description"), time_info=time_info, scheduled_time=todo_dict.get("scheduled_time"), source_text=todo_dict.get("source_text", ""), confidence=todo_dict.get("confidence"), screenshot_ids=todo_dict.get("screenshot_ids", []), ) todos.append(todo) except Exception as e: logger.warning(f"构建待办项失败,跳过: {e}") response = TodoExtractionResponse( event_id=event_id, app_name=result.get("app_name"), window_title=result.get("window_title"), event_start_time=result.get("event_start_time"), event_end_time=result.get("event_end_time"), todos=todos, extraction_timestamp=get_utc_now(), screenshot_count=result.get("screenshot_count", 0), error_message=result.get("error_message"), ) logger.info(f"待办提取完成: 事件 {event_id}, 提取到 {len(todos)} 个待办事项") return response except HTTPException: raise except Exception as e: logger.error(f"提取待办事项失败: {e}", exc_info=True) raise HTTPException( status_code=500, detail=f"提取待办事项时发生错误: {e!s}", ) from e ================================================ FILE: lifetrace/routers/vector.py ================================================ """向量数据库相关路由""" from fastapi import APIRouter, HTTPException, Query from lifetrace.core.dependencies import get_vector_service from lifetrace.schemas.event import EventResponse from lifetrace.schemas.vector import ( SemanticSearchRequest, SemanticSearchResult, VectorStatsResponse, ) from lifetrace.storage import event_mgr from lifetrace.util.logging_config import get_logger logger = get_logger() router = APIRouter(prefix="/api", tags=["vector"]) @router.post("/semantic-search", response_model=list[SemanticSearchResult]) async def semantic_search(request: SemanticSearchRequest): """语义搜索 OCR 结果""" try: vector_service = get_vector_service() if not vector_service.is_enabled(): raise HTTPException(status_code=503, detail="向量数据库服务不可用") results = vector_service.semantic_search( query=request.query, top_k=request.top_k, use_rerank=request.use_rerank, retrieve_k=request.retrieve_k, filters=request.filters, ) # 转换为响应格式 search_results = [] for result in results: search_result = SemanticSearchResult( text=result.get("text", ""), score=result.get("score", 0.0), metadata=result.get("metadata", {}), ocr_result=result.get("ocr_result"), screenshot=result.get("screenshot"), ) search_results.append(search_result) return search_results except Exception as e: logger.error(f"语义搜索失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/event-semantic-search", response_model=list[EventResponse]) async def event_semantic_search(request: SemanticSearchRequest): """事件级语义搜索(基于事件聚合文本)""" try: vector_service = get_vector_service() if not vector_service.is_enabled(): raise HTTPException(status_code=503, detail="向量数据库服务不可用") raw_results = vector_service.semantic_search_events( query=request.query, top_k=request.top_k ) # semantic_search_events 现在直接返回格式化的事件数据 events_resp: list[EventResponse] = [] for event_data in raw_results: # 检查是否已经是完整的事件数据格式 if "id" in event_data and "app_name" in event_data: # 直接使用返回的事件数据 events_resp.append(EventResponse(**event_data)) else: # 向后兼容:如果是旧格式,使用原来的逻辑 metadata = event_data.get("metadata", {}) event_id = metadata.get("event_id") if not event_id: continue matched = event_mgr.get_event_summary(int(event_id)) if matched: events_resp.append(EventResponse(**matched)) return events_resp except HTTPException: raise except Exception as e: logger.error(f"事件语义搜索失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.get("/vector-stats", response_model=VectorStatsResponse) async def get_vector_stats(): """获取向量数据库统计信息""" try: stats = get_vector_service().get_stats() return VectorStatsResponse(**stats) except Exception as e: logger.error(f"获取向量数据库统计信息失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/vector-sync") async def sync_vector_database( limit: int | None = Query(None, description="同步的最大记录数"), force_reset: bool = Query(False, description="是否强制重置向量数据库"), ): """同步 SQLite 数据库到向量数据库""" try: vector_service = get_vector_service() if not vector_service.is_enabled(): raise HTTPException(status_code=503, detail="向量数据库服务不可用") synced_count = vector_service.sync_from_database(limit=limit, force_reset=force_reset) return {"message": "同步完成", "synced_count": synced_count} except Exception as e: logger.error(f"向量数据库同步失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e @router.post("/vector-reset") async def reset_vector_database(): """重置向量数据库""" try: vector_service = get_vector_service() if not vector_service.is_enabled(): raise HTTPException(status_code=503, detail="向量数据库服务不可用") success = vector_service.reset() if success: return {"message": "向量数据库重置成功"} else: raise HTTPException(status_code=500, detail="向量数据库重置失败") except Exception as e: logger.error(f"向量数据库重置失败: {e}") raise HTTPException(status_code=500, detail=str(e)) from e ================================================ FILE: lifetrace/routers/vision.py ================================================ """视觉多模态相关路由""" from fastapi import APIRouter, HTTPException from lifetrace.core.dependencies import get_rag_service from lifetrace.schemas.vision import VisionChatRequest, VisionChatResponse from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 常量定义 MAX_SCREENSHOTS_PER_REQUEST = 20 # 一次请求最多处理的截图数量 router = APIRouter(prefix="/api/vision", tags=["vision"]) @router.post("/chat", response_model=VisionChatResponse) async def vision_chat(request: VisionChatRequest): """ 视觉多模态聊天接口 使用通义千问视觉模型分析多张截图,支持文本提示词。 Args: request: 视觉聊天请求,包含截图ID列表和提示词 Returns: 视觉聊天响应,包含模型生成的文本和元信息 Raises: HTTPException: 当请求参数无效或API调用失败时 """ try: # 验证截图ID列表 if not request.screenshot_ids: raise HTTPException( status_code=400, detail="截图ID列表不能为空,至少需要提供一个截图ID" ) if len(request.screenshot_ids) > MAX_SCREENSHOTS_PER_REQUEST: raise HTTPException( status_code=400, detail=f"一次最多只能处理{MAX_SCREENSHOTS_PER_REQUEST}张截图", ) logger.info( f"收到视觉多模态请求: {len(request.screenshot_ids)} 张截图, prompt长度: {len(request.prompt)}" ) # 检查LLM客户端是否可用 rag_service = get_rag_service() if not rag_service.llm_client.is_available(): raise HTTPException( status_code=503, detail="LLM服务当前不可用,请检查配置或稍后重试", ) # 调用视觉模型 result = rag_service.llm_client.vision_chat( screenshot_ids=request.screenshot_ids, prompt=request.prompt, model=request.model, temperature=request.temperature, max_tokens=request.max_tokens, ) # 构建响应 response = VisionChatResponse( response=result["response"], timestamp=get_utc_now(), usage_info=result.get("usage_info"), model=result.get("model"), screenshot_count=result["screenshot_count"], ) logger.info(f"视觉多模态分析完成: 处理了 {result['screenshot_count']} 张截图") return response except HTTPException: raise except ValueError as e: logger.error(f"视觉多模态请求参数错误: {e}") raise HTTPException(status_code=400, detail=str(e)) from e except RuntimeError as e: logger.error(f"视觉多模态服务不可用: {e}") raise HTTPException(status_code=503, detail=str(e)) from e except Exception as e: logger.error(f"视觉多模态分析失败: {e}", exc_info=True) raise HTTPException( status_code=500, detail=f"处理视觉多模态请求时发生错误: {e!s}", ) from e ================================================ FILE: lifetrace/schemas/__init__.py ================================================ """Pydantic 模型定义""" from lifetrace.schemas.chat import ( ChatMessage, ChatMessageWithContext, ChatResponse, NewChatRequest, NewChatResponse, ) from lifetrace.schemas.event import EventDetailResponse, EventResponse from lifetrace.schemas.screenshot import ScreenshotResponse from lifetrace.schemas.search import SearchRequest from lifetrace.schemas.stats import ( StatisticsResponse, TimeAllocationResponse, ) from lifetrace.schemas.system import ProcessInfo, SystemResourcesResponse from lifetrace.schemas.todo_extraction import ( ExtractedTodo, TodoExtractionRequest, TodoExtractionResponse, TodoTimeInfo, ) from lifetrace.schemas.vector import ( SemanticSearchRequest, SemanticSearchResult, VectorStatsResponse, ) from lifetrace.schemas.vision import VisionChatRequest, VisionChatResponse __all__ = [ "ChatMessage", "ChatMessageWithContext", "ChatResponse", "EventDetailResponse", "EventResponse", "ExtractedTodo", "NewChatRequest", "NewChatResponse", "ProcessInfo", "ScreenshotResponse", "SearchRequest", "SemanticSearchRequest", "SemanticSearchResult", "StatisticsResponse", "SystemResourcesResponse", "TimeAllocationResponse", "TodoExtractionRequest", "TodoExtractionResponse", "TodoTimeInfo", "VectorStatsResponse", "VisionChatRequest", "VisionChatResponse", ] ================================================ FILE: lifetrace/schemas/activity.py ================================================ from datetime import datetime from pydantic import BaseModel class ActivityResponse(BaseModel): id: int start_time: datetime end_time: datetime ai_title: str | None = None ai_summary: str | None = None event_count: int created_at: datetime | None = None updated_at: datetime | None = None class ActivityListResponse(BaseModel): activities: list[ActivityResponse] total_count: int class ActivityEventsResponse(BaseModel): event_ids: list[int] class ManualActivityCreateRequest(BaseModel): event_ids: list[int] class ManualActivityCreateResponse(BaseModel): id: int start_time: datetime end_time: datetime ai_title: str | None = None ai_summary: str | None = None event_count: int created_at: datetime | None = None ================================================ FILE: lifetrace/schemas/automation.py ================================================ """自动化任务相关模型""" from __future__ import annotations from typing import TYPE_CHECKING, Any from pydantic import BaseModel, Field class AutomationSchedule(BaseModel): """任务调度配置""" type: str = Field(..., description="调度类型: interval/cron/once") interval_seconds: int | None = Field(None, description="间隔秒数") cron: str | None = Field(None, description="Cron 表达式 (分钟 小时 日 月 周)") run_at: datetime | None = Field(None, description="一次性执行时间") timezone: str | None = Field(None, description="时区") class AutomationAction(BaseModel): """任务动作配置""" type: str = Field(..., description="动作类型,例如 web_fetch") payload: dict[str, Any] = Field(default_factory=dict, description="动作参数") class AutomationTaskCreate(BaseModel): """创建自动化任务请求""" name: str = Field(..., min_length=1, max_length=200, description="任务名称") description: str | None = Field(None, description="任务描述") enabled: bool = Field(True, description="是否启用") schedule: AutomationSchedule = Field(..., description="调度配置") action: AutomationAction = Field(..., description="动作配置") class AutomationTaskUpdate(BaseModel): """更新自动化任务请求""" name: str | None = Field(None, min_length=1, max_length=200, description="任务名称") description: str | None = Field(None, description="任务描述") enabled: bool | None = Field(None, description="是否启用") schedule: AutomationSchedule | None = Field(None, description="调度配置") action: AutomationAction | None = Field(None, description="动作配置") class AutomationTaskResponse(BaseModel): """自动化任务响应""" id: int = Field(..., description="任务ID") name: str = Field(..., description="任务名称") description: str | None = Field(None, description="任务描述") enabled: bool = Field(..., description="是否启用") schedule: AutomationSchedule = Field(..., description="调度配置") action: AutomationAction = Field(..., description="动作配置") last_run_at: datetime | None = Field(None, description="最后运行时间") last_status: str | None = Field(None, description="最后运行状态") last_error: str | None = Field(None, description="最后错误信息") last_output: str | None = Field(None, description="最后输出摘要") created_at: datetime = Field(..., description="创建时间") updated_at: datetime = Field(..., description="更新时间") class AutomationTaskListResponse(BaseModel): """自动化任务列表响应""" total: int tasks: list[AutomationTaskResponse] if TYPE_CHECKING: from datetime import datetime ================================================ FILE: lifetrace/schemas/chat.py ================================================ """聊天相关的 Pydantic 模型""" from datetime import datetime from typing import Any from pydantic import BaseModel, ConfigDict class ChatMessage(BaseModel): model_config = ConfigDict(extra="allow") # 允许额外字段,用于传递 Dify 等服务的参数 message: str # 发送给 LLM 的完整消息(包含 system prompt + context + user input) user_input: str | None = None # 用户真正输入的内容(用于保存到历史记录) context: str | None = None # 待办上下文(可选,用于 Agent 处理) system_prompt: str | None = None # 系统提示词(可选) conversation_id: str | None = None # 会话ID use_rag: bool = True # 是否使用RAG mode: str | None = None # 前端聊天模式(ask/plan/edit/dify_test/agno 等) # Agno Agent 工具配置 selected_tools: list[str] | None = None # FreeTodo 工具列表(如 ['create_todo', 'list_todos']) external_tools: list[str] | None = None # 外部工具列表(如 ['duckduckgo']) # Cowork 配置(本地文件操作) workspace_path: str | None = None # 工作区目录路径(用于 Cowork 模式) enable_file_delete: bool = False # 是否允许删除文件(默认不允许) def get_user_input_for_storage(self) -> str: """获取用于保存到历史记录的用户输入内容。 优先返回 user_input 字段,如果未提供则降级返回完整 message。 """ return self.user_input if self.user_input is not None else self.message class ChatMessageWithContext(BaseModel): message: str conversation_id: str | None = None event_context: list[dict[str, Any]] | None = None # 新增事件上下文 class ChatResponse(BaseModel): response: str timestamp: datetime query_info: dict[str, Any] | None = None retrieval_info: dict[str, Any] | None = None performance: dict[str, Any] | None = None session_id: str | None = None class NewChatRequest(BaseModel): session_id: str | None = None class NewChatResponse(BaseModel): session_id: str message: str timestamp: datetime class AddMessageRequest(BaseModel): role: str content: str class PlanQuestionnaireRequest(BaseModel): todo_name: str todo_id: int | None = None # 新增:用于查询上下文 session_id: str | None = None # 会话ID,用于保存聊天记录 class PlanSummaryRequest(BaseModel): todo_name: str answers: dict[str, list[str]] # question_id -> selected_options session_id: str | None = None # 会话ID,用于保存聊天记录 ================================================ FILE: lifetrace/schemas/event.py ================================================ """事件相关的 Pydantic 模型""" from datetime import datetime from pydantic import BaseModel from lifetrace.schemas.screenshot import ScreenshotResponse class EventResponse(BaseModel): id: int app_name: str | None window_title: str | None start_time: datetime end_time: datetime | None screenshot_count: int first_screenshot_id: int | None ai_title: str | None = None ai_summary: str | None = None class EventDetailResponse(BaseModel): id: int app_name: str | None window_title: str | None start_time: datetime end_time: datetime | None screenshots: list[ScreenshotResponse] ai_title: str | None = None ai_summary: str | None = None class EventListResponse(BaseModel): """事件列表响应,包含事件列表和总数""" events: list[EventResponse] total_count: int class Config: from_attributes = True ================================================ FILE: lifetrace/schemas/floating_capture.py ================================================ """悬浮窗截图提取待办相关的 Pydantic 模型""" from datetime import datetime from typing import Any from pydantic import BaseModel, Field class FloatingCaptureRequest(BaseModel): """悬浮窗截图请求模型""" image_base64: str = Field( ..., description="Base64 编码的截图数据(不含 data:image/png;base64, 前缀)" ) create_todos: bool = Field(False, description="是否自动创建待办(draft 状态)") class ExtractedTodo(BaseModel): """提取的待办项""" title: str = Field(..., description="待办标题") description: str | None = Field(None, description="待办描述") time_info: dict[str, Any] | None = Field(None, description="时间信息") source_text: str | None = Field(None, description="来源文本") confidence: float = Field(0.5, description="置信度", ge=0.0, le=1.0) class CreatedTodo(BaseModel): """创建的待办项""" id: int = Field(..., description="待办 ID") name: str = Field(..., description="待办名称") scheduled_time: str | None = Field(None, description="计划时间") class FloatingCaptureResponse(BaseModel): """悬浮窗截图响应模型""" success: bool = Field(..., description="是否成功") message: str = Field(..., description="处理消息") extracted_todos: list[ExtractedTodo] = Field(default_factory=list, description="提取的待办列表") created_todos: list[CreatedTodo] = Field(default_factory=list, description="创建的待办列表") created_count: int = Field(0, description="创建的待办数量") timestamp: datetime = Field(default_factory=datetime.now, description="响应时间戳") ================================================ FILE: lifetrace/schemas/journal.py ================================================ """日记相关的 Pydantic 模型""" from datetime import datetime from pydantic import BaseModel, Field class JournalTag(BaseModel): """日记关联的标签""" id: int = Field(..., description="标签ID") tag_name: str = Field(..., description="标签名称") class JournalCreate(BaseModel): """创建日记请求模型""" uid: str | None = Field(None, max_length=64, description="iCalendar UID") name: str | None = Field(None, max_length=200, description="日记标题") user_notes: str = Field(..., description="日记内容(富文本)") date: datetime = Field(..., description="日记日期") content_format: str = Field( "markdown", max_length=20, description="内容格式:markdown/html/json" ) content_objective: str | None = Field(None, description="客观记录") content_ai: str | None = Field(None, description="AI 视角") mood: str | None = Field(None, max_length=50, description="情绪") energy: int | None = Field(None, ge=0, le=10, description="精力") day_bucket_start: datetime | None = Field(None, description="日记归属刷新点") tags: list[str] = Field(default_factory=list, description="关联的标签列表") related_todo_ids: list[int] = Field(default_factory=list, description="关联待办ID列表") related_activity_ids: list[int] = Field(default_factory=list, description="关联活动ID列表") class JournalUpdate(BaseModel): """更新日记请求模型""" name: str | None = Field(None, max_length=200, description="日记标题") user_notes: str | None = Field(None, description="日记内容(富文本)") date: datetime | None = Field(None, description="日记日期") content_format: str | None = Field( None, max_length=20, description="内容格式:markdown/html/json" ) content_objective: str | None = Field(None, description="客观记录") content_ai: str | None = Field(None, description="AI 视角") mood: str | None = Field(None, max_length=50, description="情绪") energy: int | None = Field(None, ge=0, le=10, description="精力") day_bucket_start: datetime | None = Field(None, description="日记归属刷新点") tags: list[str] | None = Field(None, description="关联的标签列表(覆盖替换)") related_todo_ids: list[int] | None = Field(None, description="关联待办ID列表") related_activity_ids: list[int] | None = Field(None, description="关联活动ID列表") class JournalResponse(BaseModel): """日记响应模型""" id: int = Field(..., description="日记ID") uid: str = Field(..., description="iCalendar UID") name: str = Field(..., description="日记标题") user_notes: str = Field(..., description="日记内容(富文本)") date: datetime = Field(..., description="日记日期") content_format: str = Field(..., description="内容格式") content_objective: str | None = Field(None, description="客观记录") content_ai: str | None = Field(None, description="AI 视角") mood: str | None = Field(None, description="情绪") energy: int | None = Field(None, description="精力") day_bucket_start: datetime | None = Field(None, description="日记归属刷新点") created_at: datetime = Field(..., description="创建时间") updated_at: datetime = Field(..., description="更新时间") deleted_at: datetime | None = Field(None, description="删除时间") tags: list[JournalTag] = Field(default_factory=list, description="关联标签列表") related_todo_ids: list[int] = Field(default_factory=list, description="关联待办ID列表") related_activity_ids: list[int] = Field(default_factory=list, description="关联活动ID列表") class Config: from_attributes = True class JournalListResponse(BaseModel): """日记列表响应模型""" total: int = Field(..., description="总数") journals: list[JournalResponse] = Field(..., description="日记列表") class JournalAutoLinkRequest(BaseModel): """自动关联请求""" journal_id: int | None = Field(None, description="日记ID") title: str | None = Field(None, description="日记标题") content_original: str | None = Field(None, description="日记原文") date: datetime = Field(..., description="日记日期") day_bucket_start: datetime | None = Field(None, description="日记归属刷新点") max_items: int = Field(3, ge=1, le=10, description="默认关联数量") class JournalAutoLinkCandidate(BaseModel): """自动关联候选""" id: int = Field(..., description="候选ID") name: str = Field(..., description="候选标题") score: float = Field(..., description="匹配分") class JournalAutoLinkResponse(BaseModel): """自动关联响应""" related_todo_ids: list[int] = Field(default_factory=list, description="关联待办ID列表") related_activity_ids: list[int] = Field(default_factory=list, description="关联活动ID列表") todo_candidates: list[JournalAutoLinkCandidate] = Field( default_factory=list, description="待办候选" ) activity_candidates: list[JournalAutoLinkCandidate] = Field( default_factory=list, description="活动候选" ) class JournalGenerateRequest(BaseModel): """生成客观记录/AI 视角请求""" journal_id: int | None = Field(None, description="日记ID") title: str | None = Field(None, description="日记标题") content_original: str | None = Field(None, description="日记原文") date: datetime | None = Field(None, description="日记日期") day_bucket_start: datetime | None = Field(None, description="日记归属刷新点") language: str = Field("en", max_length=10, description="语言") class JournalGenerateResponse(BaseModel): """生成结果响应""" content: str = Field(..., description="生成内容") ================================================ FILE: lifetrace/schemas/message_todo_extraction.py ================================================ """从消息中提取待办相关的 Pydantic 模型""" from pydantic import BaseModel, Field class MessageTodoExtractionRequest(BaseModel): """从消息中提取待办的请求模型""" messages: list[dict[str, str]] = Field( ..., description="消息列表,包含 role 和 content 字段", ) parent_todo_id: int | None = Field( None, description="父待办ID,提取的待办将作为该待办的子待办", ) todo_context: str | None = Field( None, description="待办上下文信息,用于帮助AI理解关联的待办", ) class ExtractedMessageTodo(BaseModel): """从消息中提取的待办项结构""" name: str = Field(..., description="待办名称", min_length=1, max_length=100) description: str | None = Field(None, description="待办描述(可选)", max_length=500) tags: list[str] = Field(default_factory=list, description="标签列表") class MessageTodoExtractionResponse(BaseModel): """从消息中提取待办的响应模型""" todos: list[ExtractedMessageTodo] = Field( default_factory=list, description="提取的待办列表", ) error_message: str | None = Field(None, description="错误信息(如果有)") ================================================ FILE: lifetrace/schemas/screenshot.py ================================================ """截图相关的 Pydantic 模型""" from datetime import datetime from pydantic import BaseModel class ScreenshotResponse(BaseModel): id: int file_path: str app_name: str | None window_title: str | None created_at: datetime text_content: str | None width: int height: int file_deleted: bool = False # 文件是否已被清理 ================================================ FILE: lifetrace/schemas/search.py ================================================ """搜索相关的 Pydantic 模型""" from datetime import datetime from pydantic import BaseModel class SearchRequest(BaseModel): query: str | None = None start_date: datetime | None = None end_date: datetime | None = None app_name: str | None = None limit: int = 50 ================================================ FILE: lifetrace/schemas/stats.py ================================================ """统计相关的 Pydantic 模型""" from typing import Any from pydantic import BaseModel class StatisticsResponse(BaseModel): total_screenshots: int processed_screenshots: int today_screenshots: int processing_rate: float class TimeAllocationResponse(BaseModel): """时间分配响应模型""" total_time: int # 总使用时间(秒) daily_distribution: list[ dict[str, Any] ] # 24小时分布,格式: [{"hour": 0, "apps": {"app_name": seconds}}, ...] app_details: list[ dict[str, Any] ] # 应用详情,格式: [{"app_name": "xxx.exe", "total_time": seconds, "category": "社交"}, ...] class Config: arbitrary_types_allowed = True ================================================ FILE: lifetrace/schemas/system.py ================================================ """系统资源相关的 Pydantic 模型""" from datetime import datetime from typing import Any from pydantic import BaseModel class ProcessInfo(BaseModel): pid: int name: str cmdline: str memory_mb: float memory_vms_mb: float cpu_percent: float class SystemResourcesResponse(BaseModel): memory: dict[str, float] cpu: dict[str, Any] disk: dict[str, dict[str, float]] lifetrace_processes: list[ProcessInfo] storage: dict[str, Any] summary: dict[str, Any] timestamp: datetime class CapabilitiesResponse(BaseModel): enabled_modules: list[str] available_modules: list[str] disabled_modules: list[str] missing_deps: dict[str, list[str]] ================================================ FILE: lifetrace/schemas/todo.py ================================================ """待办事项(Todo)相关的 Pydantic 模型 说明: - 该模块面向 free-todo-frontend 的 Todo 结构(支持 deadline/priority/tags/attachments 等) - 数据库存储使用 lifetrace.storage.models 中的 Todo/Tag/Attachment 相关表 """ from datetime import datetime from enum import Enum from pydantic import BaseModel, Field class TodoStatus(str, Enum): """Todo 状态枚举(与前端保持一致)""" ACTIVE = "active" COMPLETED = "completed" CANCELED = "canceled" DRAFT = "draft" class TodoPriority(str, Enum): """Todo 优先级(与前端保持一致)""" HIGH = "high" MEDIUM = "medium" LOW = "low" NONE = "none" class TodoItemType(str, Enum): """iCalendar 条目类型""" VTODO = "VTODO" VEVENT = "VEVENT" class TodoAttachmentResponse(BaseModel): """Todo 附件响应模型""" id: int = Field(..., description="附件ID") file_name: str = Field(..., description="文件名") file_path: str = Field(..., description="文件路径") file_size: int | None = Field(None, description="文件大小(字节)") mime_type: str | None = Field(None, description="MIME 类型") source: str | None = Field(None, description="来源(user/ai)") class Config: from_attributes = True class TodoCreate(BaseModel): """创建 Todo 请求模型""" uid: str | None = Field(None, max_length=64, description="iCalendar UID") name: str = Field(..., min_length=1, max_length=200, description="待办名称") summary: str | None = Field(None, description="iCalendar SUMMARY") description: str | None = Field(None, description="描述") user_notes: str | None = Field(None, description="用户笔记") parent_todo_id: int | None = Field(None, description="父级待办ID") item_type: TodoItemType | None = Field(None, description="iCalendar 条目类型") location: str | None = Field(None, description="iCalendar LOCATION") categories: str | None = Field(None, description="iCalendar CATEGORIES") classification: str | None = Field(None, description="iCalendar CLASS") deadline: datetime | None = Field(None, description="截止时间(旧字段,逐步废弃)") start_time: datetime | None = Field(None, description="开始时间") end_time: datetime | None = Field(None, description="结束时间") dtstart: datetime | None = Field(None, description="iCalendar DTSTART") dtend: datetime | None = Field(None, description="iCalendar DTEND") due: datetime | None = Field(None, description="iCalendar DUE") duration: str | None = Field(None, description="iCalendar DURATION (ISO 8601)") time_zone: str | None = Field(None, description="时区(IANA)") tzid: str | None = Field(None, description="iCalendar TZID") is_all_day: bool | None = Field(None, description="是否全天") dtstamp: datetime | None = Field(None, description="iCalendar DTSTAMP") created: datetime | None = Field(None, description="iCalendar CREATED") last_modified: datetime | None = Field(None, description="iCalendar LAST-MODIFIED") sequence: int | None = Field(None, description="iCalendar SEQUENCE") rdate: str | None = Field(None, description="iCalendar RDATE") exdate: str | None = Field(None, description="iCalendar EXDATE") recurrence_id: datetime | None = Field(None, description="iCalendar RECURRENCE-ID") related_to_uid: str | None = Field(None, description="iCalendar RELATED-TO UID") related_to_reltype: str | None = Field(None, description="iCalendar RELATED-TO RELTYPE") ical_status: str | None = Field(None, description="iCalendar STATUS") reminder_offsets: list[int] | None = Field( None, description="提醒偏移列表(分钟,基于 dtstart/due)" ) status: TodoStatus = Field(TodoStatus.ACTIVE, description="状态") priority: TodoPriority = Field(TodoPriority.NONE, description="优先级") completed_at: datetime | None = Field(None, description="完成时间") percent_complete: int | None = Field(None, ge=0, le=100, description="完成百分比(0-100)") rrule: str | None = Field(None, description="iCalendar RRULE") order: int = Field(0, description="同级待办之间的展示排序") tags: list[str] = Field(default_factory=list, description="标签名称列表") related_activities: list[int] = Field(default_factory=list, description="关联活动ID列表") class TodoUpdate(BaseModel): """更新 Todo 请求模型(字段均可选)""" name: str | None = Field(None, min_length=1, max_length=200, description="待办名称") summary: str | None = Field(None, description="iCalendar SUMMARY") description: str | None = Field(None, description="描述") user_notes: str | None = Field(None, description="用户笔记") parent_todo_id: int | None = Field(None, description="父级待办ID(显式传 null 可清空)") item_type: TodoItemType | None = Field(None, description="iCalendar 条目类型") location: str | None = Field(None, description="iCalendar LOCATION") categories: str | None = Field(None, description="iCalendar CATEGORIES") classification: str | None = Field(None, description="iCalendar CLASS") deadline: datetime | None = Field(None, description="截止时间(旧字段,显式传 null 可清空)") start_time: datetime | None = Field(None, description="开始时间(显式传 null 可清空)") end_time: datetime | None = Field(None, description="结束时间(显式传 null 可清空)") dtstart: datetime | None = Field(None, description="iCalendar DTSTART(显式传 null 可清空)") dtend: datetime | None = Field(None, description="iCalendar DTEND(显式传 null 可清空)") due: datetime | None = Field(None, description="iCalendar DUE(显式传 null 可清空)") duration: str | None = Field(None, description="iCalendar DURATION(显式传 null 可清空)") time_zone: str | None = Field(None, description="时区(显式传 null 可清空)") tzid: str | None = Field(None, description="iCalendar TZID(显式传 null 可清空)") is_all_day: bool | None = Field(None, description="是否全天(显式传 null 可清空)") dtstamp: datetime | None = Field(None, description="iCalendar DTSTAMP(显式传 null 可清空)") created: datetime | None = Field(None, description="iCalendar CREATED(显式传 null 可清空)") last_modified: datetime | None = Field( None, description="iCalendar LAST-MODIFIED(显式传 null 可清空)" ) sequence: int | None = Field(None, description="iCalendar SEQUENCE(显式传 null 可清空)") rdate: str | None = Field(None, description="iCalendar RDATE(显式传 null 可清空)") exdate: str | None = Field(None, description="iCalendar EXDATE(显式传 null 可清空)") recurrence_id: datetime | None = Field( None, description="iCalendar RECURRENCE-ID(显式传 null 可清空)" ) related_to_uid: str | None = Field( None, description="iCalendar RELATED-TO UID(显式传 null 可清空)" ) related_to_reltype: str | None = Field( None, description="iCalendar RELATED-TO RELTYPE(显式传 null 可清空)" ) ical_status: str | None = Field(None, description="iCalendar STATUS(显式传 null 可清空)") reminder_offsets: list[int] | None = Field( None, description="提醒偏移列表(分钟,显式传 null 可回退默认)" ) status: TodoStatus | None = Field(None, description="状态") priority: TodoPriority | None = Field(None, description="优先级") completed_at: datetime | None = Field(None, description="完成时间(显式传 null 可清空)") percent_complete: int | None = Field(None, ge=0, le=100, description="完成百分比(0-100)") rrule: str | None = Field(None, description="iCalendar RRULE(显式传 null 可清空)") order: int | None = Field(None, description="同级待办之间的展示排序") tags: list[str] | None = Field(None, description="标签名称列表(显式传空数组将清空)") related_activities: list[int] | None = Field( None, description="关联活动ID列表(显式传空数组将清空)" ) class TodoResponse(BaseModel): """Todo 响应模型""" id: int = Field(..., description="待办ID") uid: str = Field(..., description="iCalendar UID") name: str = Field(..., description="待办名称") summary: str | None = Field(None, description="iCalendar SUMMARY") description: str | None = Field(None, description="描述") user_notes: str | None = Field(None, description="用户笔记") parent_todo_id: int | None = Field(None, description="父级待办ID") item_type: str | None = Field(None, description="iCalendar 条目类型") location: str | None = Field(None, description="iCalendar LOCATION") categories: str | None = Field(None, description="iCalendar CATEGORIES") classification: str | None = Field(None, description="iCalendar CLASS") deadline: datetime | None = Field(None, description="截止时间(旧字段)") start_time: datetime | None = Field(None, description="开始时间") end_time: datetime | None = Field(None, description="结束时间") dtstart: datetime | None = Field(None, description="iCalendar DTSTART") dtend: datetime | None = Field(None, description="iCalendar DTEND") due: datetime | None = Field(None, description="iCalendar DUE") duration: str | None = Field(None, description="iCalendar DURATION") time_zone: str | None = Field(None, description="时区(IANA)") tzid: str | None = Field(None, description="iCalendar TZID") is_all_day: bool = Field(False, description="是否全天") dtstamp: datetime | None = Field(None, description="iCalendar DTSTAMP") created: datetime | None = Field(None, description="iCalendar CREATED") last_modified: datetime | None = Field(None, description="iCalendar LAST-MODIFIED") sequence: int | None = Field(None, description="iCalendar SEQUENCE") rdate: str | None = Field(None, description="iCalendar RDATE") exdate: str | None = Field(None, description="iCalendar EXDATE") recurrence_id: datetime | None = Field(None, description="iCalendar RECURRENCE-ID") related_to_uid: str | None = Field(None, description="iCalendar RELATED-TO UID") related_to_reltype: str | None = Field(None, description="iCalendar RELATED-TO RELTYPE") ical_status: str | None = Field(None, description="iCalendar STATUS") reminder_offsets: list[int] | None = Field( None, description="提醒偏移列表(分钟,基于 dtstart/due)" ) status: str = Field(..., description="状态") priority: str = Field(..., description="优先级") completed_at: datetime | None = Field(None, description="完成时间") percent_complete: int = Field(0, description="完成百分比(0-100)") rrule: str | None = Field(None, description="iCalendar RRULE") order: int = Field(0, description="同级待办之间的展示排序") tags: list[str] = Field(default_factory=list, description="标签名称列表") attachments: list[TodoAttachmentResponse] = Field(default_factory=list, description="附件列表") related_activities: list[int] = Field(default_factory=list, description="关联活动ID列表") created_at: datetime = Field(..., description="创建时间") updated_at: datetime = Field(..., description="更新时间") class Config: from_attributes = True class TodoListResponse(BaseModel): """Todo 列表响应模型""" total: int = Field(..., description="总数") todos: list[TodoResponse] = Field(..., description="待办列表") class TodoReorderItem(BaseModel): """单个待办排序项""" id: int = Field(..., description="待办ID") order: int = Field(..., description="新的排序值") parent_todo_id: int | None = Field(None, description="父级待办ID(可选,用于设置父子关系)") class TodoReorderRequest(BaseModel): """批量重排序请求模型""" items: list[TodoReorderItem] = Field(..., description="待排序的待办列表") ================================================ FILE: lifetrace/schemas/todo_extraction.py ================================================ """待办提取相关的 Pydantic 模型""" from datetime import datetime from typing import Literal from pydantic import BaseModel, Field class TodoTimeInfo(BaseModel): """待办时间信息结构""" time_type: Literal["relative", "absolute"] = Field( ..., description="时间类型:relative(相对时间)或 absolute(绝对时间)" ) # 相对时间字段 relative_days: int | None = Field( None, description="相对天数(0=今天,1=明天,2=后天,-1=昨天)" ) relative_time: str | None = Field( None, description="相对时间点,24小时制格式(如:'13:00', '15:30')" ) # 绝对时间字段 absolute_time: datetime | None = Field( None, description="绝对时间(ISO 8601格式),仅在time_type为absolute时使用" ) # 原始文本 raw_text: str = Field(..., description="原始时间文本,用于验证和调试") class ExtractedTodo(BaseModel): """提取的待办项结构""" title: str = Field(..., description="待办标题", min_length=1, max_length=100) description: str | None = Field(None, description="待办描述(可选)", max_length=500) time_info: TodoTimeInfo = Field(..., description="时间信息") scheduled_time: datetime | None = Field(None, description="解析后的绝对时间(程序计算得出)") source_text: str = Field(..., description="来源文本片段,用于验证") confidence: float | None = Field(None, description="置信度(0.0-1.0),可选", ge=0.0, le=1.0) screenshot_ids: list[int] = Field(default_factory=list, description="相关的截图ID列表") class TodoExtractionRequest(BaseModel): """待办提取请求模型""" event_id: int = Field(..., description="事件ID", gt=0) screenshot_sample_ratio: int | None = Field( None, description="截图采样比例(每N张选1张),默认3", ge=1, le=10 ) class TodoExtractionResponse(BaseModel): """待办提取响应模型""" event_id: int = Field(..., description="事件ID") app_name: str | None = Field(None, description="应用名称") window_title: str | None = Field(None, description="窗口标题") event_start_time: datetime | None = Field(None, description="事件开始时间") event_end_time: datetime | None = Field(None, description="事件结束时间") todos: list[ExtractedTodo] = Field(default_factory=list, description="提取的待办列表") extraction_timestamp: datetime = Field(default_factory=datetime.now, description="提取时间戳") screenshot_count: int = Field(0, description="实际分析的截图数量") error_message: str | None = Field(None, description="错误信息(如果有)") ================================================ FILE: lifetrace/schemas/vector.py ================================================ """向量数据库相关的 Pydantic 模型""" from typing import Any from pydantic import BaseModel class SemanticSearchRequest(BaseModel): query: str top_k: int = 10 use_rerank: bool = True retrieve_k: int | None = None filters: dict[str, Any] | None = None class SemanticSearchResult(BaseModel): text: str score: float metadata: dict[str, Any] ocr_result: dict[str, Any] | None = None screenshot: dict[str, Any] | None = None class VectorStatsResponse(BaseModel): enabled: bool collection_name: str | None = None document_count: int | None = None error: str | None = None ================================================ FILE: lifetrace/schemas/vision.py ================================================ """视觉多模态相关的 Pydantic 模型""" from datetime import datetime from typing import Any from pydantic import BaseModel, Field class VisionChatRequest(BaseModel): """视觉多模态聊天请求模型""" screenshot_ids: list[int] = Field(..., description="截图ID列表,至少包含一个截图ID") prompt: str = Field(..., description="文本提示词", min_length=1) model: str | None = Field(None, description="视觉模型名称,如果不提供则使用配置中的默认模型") temperature: float | None = Field( None, description="温度参数,控制输出的随机性", ge=0.0, le=2.0 ) max_tokens: int | None = Field(None, description="最大生成token数") class VisionChatResponse(BaseModel): """视觉多模态聊天响应模型""" response: str = Field(..., description="模型生成的响应文本") timestamp: datetime = Field(default_factory=datetime.now, description="响应时间戳") usage_info: dict[str, Any] | None = Field(None, description="Token使用信息") model: str | None = Field(None, description="实际使用的模型名称") screenshot_count: int = Field(..., description="实际处理的截图数量") ================================================ FILE: lifetrace/scripts/add_file_path_column.py ================================================ #!/usr/bin/env python3 """直接添加 file_path 列到 audio_recordings 表(无需 Alembic 迁移)""" import sqlite3 from pathlib import Path def add_file_path_column(): """添加 file_path 列到 audio_recordings 表""" # 直接使用相对路径(相对于脚本位置) script_dir = Path(__file__).parent.parent db_path = script_dir / "data" / "lifetrace.db" if not Path(db_path).exists(): print(f"数据库文件不存在: {db_path}") return conn = sqlite3.connect(db_path) cursor = conn.cursor() try: # 检查列是否已存在 cursor.execute("PRAGMA table_info(audio_recordings)") columns = [row[1] for row in cursor.fetchall()] if "file_path" in columns: print("[OK] file_path column already exists, no need to add") return # 添加 file_path 列 print("Adding file_path column...") cursor.execute("ALTER TABLE audio_recordings ADD COLUMN file_path VARCHAR(500)") # 为现有记录设置默认值 cursor.execute("UPDATE audio_recordings SET file_path = '' WHERE file_path IS NULL") conn.commit() print("[OK] file_path column added successfully!") except Exception as e: conn.rollback() print(f"[ERROR] Failed to add column: {e}") raise finally: conn.close() if __name__ == "__main__": add_file_path_column() ================================================ FILE: lifetrace/scripts/build-backend.ps1 ================================================ # Build script for LifeTrace backend using PyInstaller (Windows PowerShell) # Usage: .\build-backend.ps1 $ErrorActionPreference = "Stop" # Get the script directory and project root $SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path # Script is in lifetrace/scripts/, so go up two levels to get project root $PROJECT_ROOT = Split-Path -Parent (Split-Path -Parent $SCRIPT_DIR) $LIFETRACE_DIR = Split-Path -Parent $SCRIPT_DIR $DIST_DIR = "$PROJECT_ROOT\dist-backend" $VENV_DIR = "$PROJECT_ROOT\.venv" Write-Host "Building LifeTrace backend..." Write-Host "Project root: $PROJECT_ROOT" Write-Host "Lifetrace dir: $LIFETRACE_DIR" Write-Host "Output dir: $DIST_DIR" Write-Host "Using virtual environment: $VENV_DIR" # Check if .venv exists if (-not (Test-Path $VENV_DIR)) { Write-Host "Error: Virtual environment not found at $VENV_DIR" Write-Host "Please run 'uv sync --group dev' first to create the virtual environment." exit 1 } # Check if PyInstaller is installed in .venv $VENV_PYINSTALLER = "$VENV_DIR\Scripts\pyinstaller.exe" if (-not (Test-Path $VENV_PYINSTALLER)) { Write-Host "PyInstaller not found in .venv. Installing via uv..." Set-Location $PROJECT_ROOT uv sync --group dev if (-not (Test-Path $VENV_PYINSTALLER)) { Write-Host "Error: Failed to install PyInstaller in .venv" exit 1 } } # Use .venv Python and PyInstaller $VENV_PYTHON = "$VENV_DIR\Scripts\python.exe" Write-Host "Using Python: $VENV_PYTHON" Write-Host "Using PyInstaller: $VENV_PYINSTALLER" # Verify critical dependencies are available in .venv Write-Host "Verifying dependencies in .venv..." try { & $VENV_PYTHON -c "import fastapi, uvicorn, pydantic; print('✓ All critical dependencies found')" if ($LASTEXITCODE -ne 0) { throw "Dependency check failed" } } catch { Write-Host "Error: Missing dependencies in .venv. Please run 'uv sync --group dev' first." exit 1 } # Clean previous build if (Test-Path $DIST_DIR) { Write-Host "Cleaning previous build..." Remove-Item -Recurse -Force $DIST_DIR } # Create dist directory New-Item -ItemType Directory -Force -Path $DIST_DIR | Out-Null # Change to project root directory Set-Location $PROJECT_ROOT # Run PyInstaller using .venv Python Write-Host "Running PyInstaller..." # Change to lifetrace directory to run PyInstaller (so paths in spec file work correctly) Set-Location $LIFETRACE_DIR # Use .venv Python explicitly to ensure all dependencies are from .venv & $VENV_PYTHON -m PyInstaller --clean --noconfirm pyinstaller.spec # Copy the built executable to dist-backend # PyInstaller creates a directory with the same name as the spec file target # PyInstaller runs from LIFETRACE_DIR, so dist is created there $BUILD_DIR = "$LIFETRACE_DIR\dist\lifetrace" if (Test-Path $BUILD_DIR) { Write-Host "Copying build output to $DIST_DIR..." Copy-Item -Recurse -Force "$BUILD_DIR\*" $DIST_DIR # 将 config 和 models 从 _internal 复制到 app 根目录(与 _internal 同级别) # 这样在打包环境中,路径为 backend\config\ 和 backend\models\ $internalConfig = "$DIST_DIR\_internal\config" if (Test-Path $internalConfig) { Write-Host "Copying config files to app root..." $appConfig = "$DIST_DIR\config" New-Item -ItemType Directory -Path $appConfig -Force | Out-Null Copy-Item -Path "$internalConfig\*" -Destination $appConfig -Recurse -Force -ErrorAction SilentlyContinue } $internalModels = "$DIST_DIR\_internal\models" if (Test-Path $internalModels) { Write-Host "Copying model files to app root..." $appModels = "$DIST_DIR\models" New-Item -ItemType Directory -Path $appModels -Force | Out-Null Copy-Item -Path "$internalModels\*" -Destination $appModels -Recurse -Force -ErrorAction SilentlyContinue } Write-Host "Backend build complete! Output: $DIST_DIR" Write-Host "Backend executable location: $DIST_DIR\lifetrace.exe" Write-Host "Config directory: $DIST_DIR\config" Write-Host "Models directory: $DIST_DIR\models" } else { Write-Host "Error: Build directory not found: $BUILD_DIR" $DIST_PARENT = "$PROJECT_ROOT\dist" if (Test-Path $DIST_PARENT) { Write-Host "Available directories in dist:" Get-ChildItem $DIST_PARENT | ForEach-Object { Write-Host " $($_.Name)" } } else { Write-Host "dist directory does not exist" } exit 1 } ================================================ FILE: lifetrace/scripts/build-backend.sh ================================================ #!/bin/bash # Build script for LifeTrace backend using PyInstaller # Usage: ./build-backend.sh # Supports: macOS, Linux, Windows (via WSL/Git Bash/MSYS2) set -e # Exit on error # Get the script directory and project root SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Script is in lifetrace/scripts/, so go up two levels to get project root PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" LIFETRACE_DIR="$SCRIPT_DIR/.." DIST_DIR="$PROJECT_ROOT/dist-backend" VENV_DIR="$PROJECT_ROOT/.venv" # Detect platform and set paths accordingly detect_platform() { case "$(uname -s)" in Linux*) # Check if running in WSL if grep -qi microsoft /proc/version 2>/dev/null; then echo "windows" else echo "linux" fi ;; Darwin*) echo "macos" ;; MINGW*|MSYS*|CYGWIN*) echo "windows" ;; *) echo "unknown" ;; esac } PLATFORM=$(detect_platform) echo "Detected platform: $PLATFORM" # Set platform-specific paths if [ "$PLATFORM" = "windows" ]; then # Windows uses Scripts/ directory and .exe extension VENV_BIN_DIR="$VENV_DIR/Scripts" VENV_PYTHON="$VENV_BIN_DIR/python.exe" VENV_PYINSTALLER="$VENV_BIN_DIR/pyinstaller.exe" else # macOS and Linux use bin/ directory VENV_BIN_DIR="$VENV_DIR/bin" VENV_PYTHON="$VENV_BIN_DIR/python" VENV_PYINSTALLER="$VENV_BIN_DIR/pyinstaller" fi echo "Building LifeTrace backend..." echo "Project root: $PROJECT_ROOT" echo "Lifetrace dir: $LIFETRACE_DIR" echo "Output dir: $DIST_DIR" echo "Using virtual environment: $VENV_DIR" # Check if .venv exists if [ ! -d "$VENV_DIR" ]; then echo "Error: Virtual environment not found at $VENV_DIR" echo "Please run 'uv sync --group dev' first to create the virtual environment." exit 1 fi # Check if PyInstaller is installed in .venv if [ ! -f "$VENV_PYINSTALLER" ]; then echo "PyInstaller not found in .venv at: $VENV_PYINSTALLER" echo "Attempting to install via uv..." cd "$PROJECT_ROOT" # Try to find uv command if command -v uv &> /dev/null; then uv sync --group dev elif [ "$PLATFORM" = "windows" ]; then # On Windows/WSL, try common paths for uv if [ -f "$HOME/.local/bin/uv" ]; then "$HOME/.local/bin/uv" sync --group dev elif [ -f "$HOME/.cargo/bin/uv" ]; then "$HOME/.cargo/bin/uv" sync --group dev else echo "Error: 'uv' command not found." echo "Please install dependencies manually:" echo " 1. In PowerShell: uv sync --group dev" echo " 2. Or install uv in WSL: curl -LsSf https://astral.sh/uv/install.sh | sh" exit 1 fi else echo "Error: 'uv' command not found. Please install it first:" echo " curl -LsSf https://astral.sh/uv/install.sh | sh" exit 1 fi if [ ! -f "$VENV_PYINSTALLER" ]; then echo "Error: Failed to install PyInstaller in .venv" echo "Expected location: $VENV_PYINSTALLER" exit 1 fi fi echo "Using Python: $VENV_PYTHON" echo "Using PyInstaller: $VENV_PYINSTALLER" # Verify critical dependencies are available in .venv echo "Verifying dependencies in .venv..." "$VENV_PYTHON" -c "import fastapi, uvicorn, pydantic; print('All critical dependencies found')" || { echo "Error: Missing dependencies in .venv. Please run 'uv sync --group dev' first." exit 1 } # Clean previous build if [ -d "$DIST_DIR" ]; then echo "Cleaning previous build..." rm -rf "$DIST_DIR" fi # Create dist directory mkdir -p "$DIST_DIR" # Change to project root directory cd "$PROJECT_ROOT" # Run PyInstaller using .venv Python echo "Running PyInstaller..." # Change to lifetrace directory to run PyInstaller (so paths in spec file work correctly) cd "$LIFETRACE_DIR" # Use .venv Python explicitly to ensure all dependencies are from .venv "$VENV_PYTHON" -m PyInstaller --clean --noconfirm pyinstaller.spec # Copy the built executable to dist-backend # PyInstaller creates a directory with the same name as the spec file target # PyInstaller runs from LIFETRACE_DIR, so dist is created there BUILD_DIR="$LIFETRACE_DIR/dist/lifetrace" if [ -d "$BUILD_DIR" ]; then echo "Copying build output to $DIST_DIR..." cp -r "$BUILD_DIR"/* "$DIST_DIR/" # Copy config and models from _internal to app root (same level as _internal) # So in packaged environment, paths are backend/config/ and backend/models/ if [ -d "$DIST_DIR/_internal/config" ]; then echo "Copying config files to app root..." mkdir -p "$DIST_DIR/config" cp -r "$DIST_DIR/_internal/config"/* "$DIST_DIR/config/" 2>/dev/null || true fi if [ -d "$DIST_DIR/_internal/models" ]; then echo "Copying model files to app root..." mkdir -p "$DIST_DIR/models" cp -r "$DIST_DIR/_internal/models"/* "$DIST_DIR/models/" 2>/dev/null || true fi echo "Backend build complete! Output: $DIST_DIR" if [ "$PLATFORM" = "windows" ]; then echo "Backend executable location: $DIST_DIR/lifetrace.exe" else echo "Backend executable location: $DIST_DIR/lifetrace" fi echo "Config directory: $DIST_DIR/config" echo "Models directory: $DIST_DIR/models" else echo "Error: Build directory not found: $BUILD_DIR" echo "Available directories in dist:" ls -la "$PROJECT_ROOT/dist" 2>/dev/null || echo "dist directory does not exist" exit 1 fi ================================================ FILE: lifetrace/scripts/check_code_lines.py ================================================ #!/usr/bin/env python3 """ Check effective Python code lines (excluding blank lines and comments). Files over the limit are reported and the script exits non-zero. Usage: # Scan the whole directory (standalone) python check_code_lines.py [--include dirs] [--exclude dirs] [--max lines] # Check specific files (pre-commit mode) python check_code_lines.py [options] file1.py file2.py ... Examples: # Scan the entire lifetrace directory python check_code_lines.py --include lifetrace --exclude lifetrace/__pycache__,lifetrace/dist --max 500 # Check specific files (pre-commit passes staged files) python check_code_lines.py lifetrace/routers/chat.py lifetrace/services/todo.py """ import argparse import sys from pathlib import Path # Default configuration DEFAULT_INCLUDE = ["lifetrace"] DEFAULT_EXCLUDE = [ "lifetrace/__pycache__", "lifetrace/dist", "lifetrace/migrations/versions", ] DEFAULT_MAX_LINES = 500 def parse_args() -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( description="Check effective Python code lines (excluding blank lines and comments)." ) parser.add_argument( "files", nargs="*", help="Files to check (if omitted, scan the entire directory).", ) parser.add_argument( "--include", type=str, default=",".join(DEFAULT_INCLUDE), help=( f"Comma-separated directory prefixes to include (default: {','.join(DEFAULT_INCLUDE)})" ), ) parser.add_argument( "--exclude", type=str, default=",".join(DEFAULT_EXCLUDE), help=( f"Comma-separated directory prefixes to exclude (default: {','.join(DEFAULT_EXCLUDE)})" ), ) parser.add_argument( "--max", type=int, default=DEFAULT_MAX_LINES, help=f"Maximum allowed code lines (default: {DEFAULT_MAX_LINES}).", ) return parser.parse_args() def count_code_lines(file_path: Path) -> int: """ Count effective code lines (excluding blank lines and comment-only lines). Rules: - Blank lines (strip() == ""): not counted - Lines starting with "#": not counted - All other lines: counted """ code_lines = 0 try: with open(file_path, encoding="utf-8") as f: for line in f: stripped = line.strip() # Skip blank lines if not stripped: continue # Skip comment-only lines if stripped.startswith("#"): continue # Counted line code_lines += 1 except (OSError, UnicodeDecodeError) as e: print(f"Warning: failed to read file {file_path}: {e}", file=sys.stderr) return 0 return code_lines def should_check_file( file_path: Path, root_dir: Path, include_dirs: list[str], exclude_dirs: list[str] ) -> bool: """ Determine whether a file should be checked. Args: file_path: File path root_dir: Project root directory include_dirs: Directory prefixes to include exclude_dirs: Directory prefixes to exclude Returns: True if the file should be checked; otherwise False """ # Get path relative to the project root try: rel_path = file_path.relative_to(root_dir) except ValueError: return False # Normalize to forward slashes to avoid Windows separator issues rel_path_str = str(rel_path).replace("\\", "/") # Check include directories in_include = any(rel_path_str.startswith(inc.replace("\\", "/")) for inc in include_dirs) if not in_include: return False # Check exclude directories in_exclude = any(rel_path_str.startswith(exc.replace("\\", "/")) for exc in exclude_dirs) return not in_exclude def get_files_to_check( args: argparse.Namespace, root_dir: Path, include_dirs: list[str], exclude_dirs: list[str] ) -> list[Path]: """ Get the list of files to check. Args: args: Parsed command-line arguments root_dir: Project root directory include_dirs: Directory prefixes to include exclude_dirs: Directory prefixes to exclude Returns: List of file paths to check """ files_to_check: list[Path] = [] if args.files: # Mode 1: Check specified files (pre-commit mode) for file_str in args.files: file_path = Path(file_str).resolve() # Only check .py files if file_path.suffix != ".py": continue # Skip missing files if not file_path.exists(): continue # Check include/exclude filters if should_check_file(file_path, root_dir, include_dirs, exclude_dirs): files_to_check.append(file_path) else: # Mode 2: Scan entire directory (standalone mode) for py_file in root_dir.rglob("*.py"): if should_check_file(py_file, root_dir, include_dirs, exclude_dirs): files_to_check.append(py_file) return files_to_check def main() -> int: """Main entrypoint.""" args = parse_args() # Parse arguments include_dirs = [d.strip() for d in args.include.split(",") if d.strip()] exclude_dirs = [d.strip() for d in args.exclude.split(",") if d.strip()] max_lines = args.max # Project root (script lives in lifetrace/scripts/) script_dir = Path(__file__).resolve().parent root_dir = script_dir.parent.parent # Collect files to check files_to_check = get_files_to_check(args, root_dir, include_dirs, exclude_dirs) if not files_to_check: if args.files: # No matching files in pre-commit mode return 0 else: print("No Python files to check.") return 0 # Collect violations violations: list[tuple[str, int]] = [] for py_file in files_to_check: code_lines = count_code_lines(py_file) if code_lines > max_lines: rel_path = py_file.relative_to(root_dir) violations.append((str(rel_path), code_lines)) # Output results if violations: print(f"[ERROR] The following files exceed {max_lines} code lines:") for path, lines in sorted(violations): print(f" {path} -> {lines} lines") return 1 else: mode_desc = f"Checked {len(files_to_check)} files, " if args.files else "" print(f"[OK] {mode_desc}all Python files are within {max_lines} code lines") return 0 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: lifetrace/scripts/fix_audio_recordings_table.py ================================================ #!/usr/bin/env python3 """修复 audio_recordings 表,添加所有缺失的列""" import sqlite3 from pathlib import Path def fix_audio_recordings_table(): """修复 audio_recordings 表结构""" # 直接使用相对路径(相对于脚本位置) script_dir = Path(__file__).parent.parent db_path = script_dir / "data" / "lifetrace.db" if not Path(db_path).exists(): print(f"Database file not found: {db_path}") return conn = sqlite3.connect(db_path) cursor = conn.cursor() try: # 检查表是否存在 cursor.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='audio_recordings'" ) if not cursor.fetchone(): print("Table audio_recordings does not exist, creating...") # 创建完整的表 cursor.execute(""" CREATE TABLE audio_recordings ( id INTEGER PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME, file_path VARCHAR(500) NOT NULL, file_size INTEGER NOT NULL, duration REAL NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME, status VARCHAR(20) NOT NULL DEFAULT 'recording', is_24x7 BOOLEAN NOT NULL DEFAULT 0, transcription_status VARCHAR(20) NOT NULL DEFAULT 'pending' ) """) print("[OK] Table created successfully!") conn.commit() return # 表存在,检查并添加缺失的列 cursor.execute("PRAGMA table_info(audio_recordings)") existing_columns = {row[1]: row for row in cursor.fetchall()} # 需要添加的列 columns_to_add = { "file_path": "VARCHAR(500)", "file_size": "INTEGER", "duration": "REAL", "start_time": "DATETIME", "end_time": "DATETIME", "status": "VARCHAR(20)", "is_24x7": "BOOLEAN", "is_transcribed": "BOOLEAN", "is_extracted": "BOOLEAN", "is_summarized": "BOOLEAN", "is_full_audio": "BOOLEAN", "is_segment_audio": "BOOLEAN", "transcription_status": "VARCHAR(20)", "created_at": "DATETIME", "updated_at": "DATETIME", "deleted_at": "DATETIME", } added_columns = [] for col_name, col_type in columns_to_add.items(): if col_name not in existing_columns: try: cursor.execute(f"ALTER TABLE audio_recordings ADD COLUMN {col_name} {col_type}") added_columns.append(col_name) print(f"Added column: {col_name}") except sqlite3.OperationalError as e: print(f"Failed to add column {col_name}: {e}") # 设置默认值 if added_columns: cursor.execute("UPDATE audio_recordings SET file_path = '' WHERE file_path IS NULL") cursor.execute("UPDATE audio_recordings SET file_size = 0 WHERE file_size IS NULL") cursor.execute("UPDATE audio_recordings SET duration = 0 WHERE duration IS NULL") cursor.execute("UPDATE audio_recordings SET status = 'recording' WHERE status IS NULL") cursor.execute("UPDATE audio_recordings SET is_24x7 = 0 WHERE is_24x7 IS NULL") cursor.execute( "UPDATE audio_recordings SET is_transcribed = 0 WHERE is_transcribed IS NULL" ) cursor.execute( "UPDATE audio_recordings SET is_extracted = 0 WHERE is_extracted IS NULL" ) cursor.execute( "UPDATE audio_recordings SET is_summarized = 0 WHERE is_summarized IS NULL" ) cursor.execute( "UPDATE audio_recordings SET is_full_audio = 0 WHERE is_full_audio IS NULL" ) cursor.execute( "UPDATE audio_recordings SET is_segment_audio = 0 WHERE is_segment_audio IS NULL" ) cursor.execute( "UPDATE audio_recordings SET transcription_status = 'pending' WHERE transcription_status IS NULL" ) conn.commit() if added_columns: print(f"[OK] Added {len(added_columns)} columns: {', '.join(added_columns)}") else: print("[OK] All columns already exist, no changes needed") except Exception as e: conn.rollback() print(f"[ERROR] Failed to fix table: {e}") raise finally: conn.close() if __name__ == "__main__": fix_audio_recordings_table() ================================================ FILE: lifetrace/scripts/fix_transcriptions_table.py ================================================ #!/usr/bin/env python3 """ 修复 transcriptions 表缺失问题。 用法: python lifetrace/scripts/fix_transcriptions_table.py """ import sqlite3 from pathlib import Path def ensure_transcriptions_table(db_path: Path) -> None: if not db_path.exists(): print(f"数据库不存在:{db_path}") return conn = sqlite3.connect(db_path) cur = conn.cursor() try: cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='transcriptions'") exists = cur.fetchone() if exists: print("[OK] transcriptions 表已存在,跳过创建") return print("[INFO] transcriptions 表不存在,开始创建...") cur.execute( """ CREATE TABLE transcriptions ( id INTEGER PRIMARY KEY, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, deleted_at DATETIME, audio_recording_id INTEGER NOT NULL, original_text TEXT, optimized_text TEXT, extraction_status VARCHAR(20) NOT NULL DEFAULT 'pending', extracted_todos TEXT, extracted_schedules TEXT ) """ ) conn.commit() print("[OK] transcriptions 表已创建") finally: conn.close() if __name__ == "__main__": db_path = Path(__file__).parent.parent / "data" / "lifetrace.db" ensure_transcriptions_table(db_path) ================================================ FILE: lifetrace/scripts/start_backend.py ================================================ #!/usr/bin/env python3 """ Backend startup wrapper script for LifeTrace Handles data directory setup and config initialization before starting the FastAPI server """ import argparse import contextlib import importlib import os import shutil import sys import traceback from pathlib import Path # Handle PyInstaller bundled application if getattr(sys, "frozen", False): # PyInstaller bundled - use _MEIPASS for resource path # In one-folder bundle, _MEIPASS points to _internal directory bundle_path = getattr(sys, "_MEIPASS", None) if bundle_path: # Add _internal to path where lifetrace modules are located sys.path.insert(0, bundle_path) else: # Fallback: try to find _internal directory relative to executable bundle_dir = Path(sys.executable).parent internal_dir = bundle_dir / "_internal" if internal_dir.exists(): sys.path.insert(0, str(internal_dir)) else: # Last resort: use bundle directory sys.path.insert(0, str(bundle_dir)) else: # Development mode - add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) # Import loguru first to ensure PyInstaller detects it with contextlib.suppress(ImportError): import loguru # noqa: F401 from lifetrace.util.base_paths import get_config_dir from lifetrace.util.logging_config import get_logger logger = get_logger() def setup_data_directory(data_dir: str) -> None: """Set up the data directory structure and initialize config files if needed""" data_path = Path(data_dir) # Create directory structure config_dir = data_path / "config" data_subdir = data_path / "data" logs_dir = data_path / "logs" for directory in [config_dir, data_subdir, logs_dir]: directory.mkdir(parents=True, exist_ok=True) logger.info(f"Ensured directory exists: {directory}") # Copy config files if they don't exist # Get the source config directory (from PyInstaller bundle or development) if getattr(sys, "frozen", False): # PyInstaller bundled - in one-folder bundle, files are in _internal/ # The executable is at bundle_dir/lifetrace, config is at bundle_dir/_internal/config bundle_dir = Path(sys.executable).parent # Try _internal/config first (one-folder bundle), then config (if files are at root) potential_config_dirs = [ bundle_dir / "_internal" / "config", bundle_dir / "config", ] source_config_dir = None for potential_config_dir in potential_config_dirs: if ( potential_config_dir.exists() and (potential_config_dir / "default_config.yaml").exists() ): source_config_dir = potential_config_dir logger.info(f"Found config directory: {source_config_dir}") break if source_config_dir is None: logger.warning( f"Could not find config directory in bundle. Tried: {potential_config_dirs}" ) # Fallback to bundle_dir/config source_config_dir = bundle_dir / "config" else: # Development mode source_config_dir = get_config_dir() # Copy default config files if they don't exist in data directory config_files = ["default_config.yaml", "prompt.yaml", "rapidocr_config.yaml"] for config_file in config_files: source_file = source_config_dir / config_file dest_file = config_dir / config_file if source_file.exists() and not dest_file.exists(): shutil.copy2(source_file, dest_file) logger.info(f"Copied config file: {config_file}") elif not source_file.exists(): logger.warning(f"Source config file not found: {source_file}") # Initialize config.yaml from default_config.yaml if it doesn't exist default_config = config_dir / "default_config.yaml" config_yaml = config_dir / "config.yaml" if default_config.exists() and not config_yaml.exists(): shutil.copy2(default_config, config_yaml) logger.info("Initialized config.yaml from default_config.yaml") def main(): """Main entry point""" parser = argparse.ArgumentParser(description="LifeTrace Backend Server") parser.add_argument( "--data-dir", type=str, help="Data directory path (default: current directory)", default=None, ) parser.add_argument( "--port", type=int, help="Server port (default: 8001)", default=8001, ) parser.add_argument( "--host", type=str, help="Server host (default: 127.0.0.1)", default="127.0.0.1", ) parser.add_argument( "--mode", type=str, choices=["dev", "build"], default="dev", help="Server mode: dev (development) or build (packaged app)", ) args = parser.parse_args() # Set data directory environment variable if provided if args.data_dir: os.environ["LIFETRACE_DATA_DIR"] = args.data_dir setup_data_directory(args.data_dir) logger.info(f"Using data directory: {args.data_dir}") else: # Use current directory as fallback current_dir = os.getcwd() logger.info(f"No data directory specified, using current directory: {current_dir}") # Import and start the server # The config module will read LIFETRACE_DATA_DIR environment variable # Note: In PyInstaller bundle, lifetrace modules should be in sys._MEIPASS try: uvicorn = importlib.import_module("uvicorn") health_module = importlib.import_module("lifetrace.routers.health") server_module = importlib.import_module("lifetrace.server") settings_module = importlib.import_module("lifetrace.util.settings") set_server_mode = health_module.set_server_mode app = server_module.app settings = settings_module.settings # Set server mode for health check endpoint set_server_mode(args.mode) logger.info(f"Server mode: {args.mode}") except ImportError as e: # If import fails, log the error with path information error_info = f""" Import Error: {e} sys.path: {sys.path} sys._MEIPASS: {getattr(sys, "_MEIPASS", "Not set")} sys.executable: {sys.executable} sys.frozen: {getattr(sys, "frozen", False)} """ print(error_info, file=sys.stderr) print(traceback.format_exc(), file=sys.stderr) if logger: logger.error(f"Failed to import lifetrace modules: {error_info}") raise # Override server config if provided via command line if args.port: settings.set("server.port", args.port) if args.host: settings.set("server.host", args.host) server_host = settings.server.host server_port = settings.server.port server_debug = settings.server.debug logger.info("Starting LifeTrace backend server") logger.info(f"Server URL: http://{server_host}:{server_port}") logger.info(f"Debug mode: {'enabled' if server_debug else 'disabled'}") logger.info(f"Data directory: {os.environ.get('LIFETRACE_DATA_DIR', 'default')}") # Start the server uvicorn.run( app, host=server_host, port=server_port, reload=server_debug, access_log=server_debug, log_level="debug" if server_debug else "info", ) if __name__ == "__main__": try: main() except Exception as e: # Ensure errors are logged and visible error_msg = f"Fatal error in backend startup: {e}\n{traceback.format_exc()}" print(error_msg, file=sys.stderr) if logger: logger.error(error_msg) sys.exit(1) ================================================ FILE: lifetrace/server.py ================================================ import argparse import asyncio import socket from contextlib import asynccontextmanager, suppress import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from lifetrace.core.module_registry import ( MODULES, get_enabled_module_ids, get_module_states, log_module_summary, register_modules, ) from lifetrace.jobs.job_manager import get_job_manager from lifetrace.services.config_service import is_llm_configured from lifetrace.util.base_paths import get_user_logs_dir from lifetrace.util.logging_config import get_logger, setup_logging from lifetrace.util.settings import settings # 使用处理后的日志路径配置 logging_config = settings.get("logging").copy() logging_config["log_path"] = str(get_user_logs_dir()) + "/" setup_logging(logging_config) logger = get_logger() PRIORITY_MODULES = ("health", "config", "system", "todo") @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期管理""" # 启动逻辑 logger.info("Web服务器启动") # 初始化任务管理器 manager = get_job_manager() app.state.job_manager = manager background_tasks = [] app.state.background_tasks = background_tasks # 延迟启动后台任务,避免阻塞启动流程 background_tasks.append(asyncio.create_task(_start_job_manager_async(app))) # 延迟加载非优先模块 background_tasks.append(asyncio.create_task(_register_deferred_modules(app))) # 延迟验证 LLM 连接 background_tasks.append(asyncio.create_task(_verify_llm_connection_async())) yield # 关闭逻辑 logger.error("Web服务器关闭,正在停止后台服务") # 停止后台任务 for task in getattr(app.state, "background_tasks", []): task.cancel() with suppress(asyncio.CancelledError): await task # 停止所有后台任务 manager = getattr(app.state, "job_manager", None) if manager: manager.stop_all() app = FastAPI( title="FreeTodo API", description="FreeTodo API (part of FreeU Project)", version="0.1.2", lifespan=lifespan, ) def get_cors_origins() -> list[str]: """ 生成 CORS 允许的来源列表,支持动态端口。 为了支持 Build 版和开发版同时运行,需要允许端口范围: - 前端端口范围:3000-3200(包括 3200,Build 版默认端口) - 后端端口范围:8000-8200(包括 8200,Build 版默认端口) """ origins = [] # 前端端口范围 3000-3200(包括 3200) for port in range(3000, 3201): origins.extend([f"http://localhost:{port}", f"http://127.0.0.1:{port}"]) # 后端端口范围 8000-8200(包括 8200) for port in range(8000, 8201): origins.extend([f"http://localhost:{port}", f"http://127.0.0.1:{port}"]) return origins app.add_middleware( CORSMiddleware, allow_origins=get_cors_origins(), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Session-Id"], # 允许前端读取会话ID,支持多轮对话 ) # 向量服务、RAG服务和OCR处理器均改为延迟加载 # 通过 lifetrace.core.dependencies 模块按需获取 # 全局配置状态标志 llm_configured = is_llm_configured() config_status = "已配置" if llm_configured else "未配置,需要引导配置" logger.info(f"LLM配置状态: {config_status}") def _order_modules(module_ids: list[str]) -> list[str]: module_id_set = set(module_ids) return [module.id for module in MODULES if module.id in module_id_set] def _register_priority_modules(app: FastAPI) -> None: states = get_module_states() log_module_summary(states) enabled_ids = get_enabled_module_ids(states) priority_ids = _order_modules([mid for mid in enabled_ids if mid in PRIORITY_MODULES]) deferred_ids = _order_modules([mid for mid in enabled_ids if mid not in PRIORITY_MODULES]) registered = register_modules(app, priority_ids, states=states) app.state.registered_modules = set(registered) app.state.deferred_modules = [ mid for mid in deferred_ids if mid not in app.state.registered_modules ] logger.info(f"快速启动:优先加载模块: {', '.join(priority_ids) or 'none'}") if app.state.deferred_modules: logger.info(f"延迟加载模块: {', '.join(app.state.deferred_modules)}") async def _register_deferred_modules(app: FastAPI) -> None: deferred_modules = getattr(app.state, "deferred_modules", []) if not deferred_modules: return logger.info(f"开始延迟加载 {len(deferred_modules)} 个模块") for module_id in deferred_modules: registered = register_modules(app, [module_id]) if registered: app.state.registered_modules.update(registered) await asyncio.sleep(0) logger.info("延迟模块加载完成") async def _start_job_manager_async(app: FastAPI) -> None: manager = getattr(app.state, "job_manager", None) if not manager: return await asyncio.to_thread(manager.start_all) async def _verify_llm_connection_async() -> None: try: from lifetrace.routers.config import ( # noqa: PLC0415 verify_llm_connection_on_startup, ) except Exception as exc: logger.debug(f"LLM 验证初始化跳过: {exc}") return await asyncio.to_thread(verify_llm_connection_on_startup) # 注册按配置启用的路由 _register_priority_modules(app) def find_available_port(host: str, start_port: int, max_attempts: int = 100) -> int: """ 查找可用端口。 从 start_port 开始,依次尝试直到找到可用端口。 支持 Build 版和开发版同时运行,自动避免端口冲突。 Args: host: 绑定的主机地址 start_port: 起始端口号 max_attempts: 最大尝试次数 Returns: 可用的端口号 Raises: RuntimeError: 如果在指定范围内找不到可用端口 """ for offset in range(max_attempts): port = start_port + offset try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind((host, port)) if offset > 0: logger.info(f"端口 {start_port} 已被占用,使用端口 {port}") return port except OSError: continue raise RuntimeError(f"无法在 {start_port}-{start_port + max_attempts} 范围内找到可用端口") def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser(description="LifeTrace 后端服务器") parser.add_argument( "--port", type=int, default=None, help="服务器端口号(默认从配置文件读取)", ) parser.add_argument( "--data-dir", type=str, default=None, help="数据目录路径", ) parser.add_argument( "--mode", type=str, choices=["dev", "build"], default="dev", help="服务器模式:dev(开发模式)或 build(打包模式)", ) return parser.parse_args() if __name__ == "__main__": args = parse_args() # 设置服务器模式 from lifetrace.routers.health import set_server_mode set_server_mode(args.mode) server_host = settings.server.host server_port = args.port if args.port else settings.server.port server_debug = settings.server.debug # 动态端口分配:如果默认端口被占用,自动尝试下一个可用端口 try: actual_port = find_available_port(server_host, server_port) except RuntimeError as e: logger.error(f"端口分配失败: {e}") raise logger.info(f"启动服务器: http://{server_host}:{actual_port}") logger.info(f"服务器模式: {args.mode}") logger.info(f"调试模式: {'开启' if server_debug else '关闭'}") if actual_port != server_port: logger.info(f"注意: 原始端口 {server_port} 已被占用,已自动切换到 {actual_port}") uvicorn.run( "lifetrace.server:app", host=server_host, port=actual_port, reload=server_debug, access_log=server_debug, log_level="debug" if server_debug else "info", ) ================================================ FILE: lifetrace/services/__init__.py ================================================ """服务层 - 业务逻辑服务""" from lifetrace.services.config_service import ConfigService __all__ = ["ConfigService"] ================================================ FILE: lifetrace/services/activity_service.py ================================================ """Activity 业务逻辑层 处理 Activity 相关的业务逻辑,与数据访问层解耦。 """ import importlib from datetime import datetime from fastapi import HTTPException from lifetrace.repositories.interfaces import IActivityRepository, IEventRepository from lifetrace.schemas.activity import ( ActivityEventsResponse, ActivityListResponse, ActivityResponse, ManualActivityCreateRequest, ManualActivityCreateResponse, ) from lifetrace.util.logging_config import get_logger logger = get_logger() class ActivityService: """Activity 业务逻辑层""" def __init__( self, activity_repository: IActivityRepository, event_repository: IEventRepository, ): self.activity_repo = activity_repository self.event_repo = event_repository def list_activities( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, ) -> ActivityListResponse: """获取活动列表""" logger.info( f"获取活动列表 - 参数: limit={limit}, offset={offset}, " f"start_date={start_date}, end_date={end_date}" ) activities = self.activity_repo.get_activities( limit=limit, offset=offset, start_date=start_date, end_date=end_date, ) total_count = self.activity_repo.count_activities( start_date=start_date, end_date=end_date, ) logger.info( f"获取活动列表 - 结果: activities_count={len(activities)}, total_count={total_count}" ) return ActivityListResponse( activities=[ActivityResponse(**a) for a in activities], total_count=total_count, ) def get_activity_events(self, activity_id: int) -> ActivityEventsResponse: """获取指定活动关联的事件ID列表""" logger.info(f"获取活动 {activity_id} 的事件列表") event_ids = self.activity_repo.get_activity_events(activity_id) return ActivityEventsResponse(event_ids=event_ids) def create_activity_manual( self, request: ManualActivityCreateRequest ) -> ManualActivityCreateResponse: """手动聚合指定事件集合为活动""" self._validate_event_ids(request.event_ids) logger.info(f"手动聚合活动 - 事件ID列表: {request.event_ids}") events = self._get_and_validate_events(request.event_ids) self._validate_events_ended(events) self._validate_events_not_linked(events) activity_start_time, activity_end_time = self._calculate_activity_time_range(events) events_data = self._prepare_events_data(events) created_activity = self._create_activity_with_summary( events_data, activity_start_time, activity_end_time, request.event_ids ) return ManualActivityCreateResponse(**created_activity) def _validate_event_ids(self, event_ids: list[int]) -> None: """验证事件ID列表不为空""" if not event_ids: raise HTTPException(status_code=400, detail="事件ID列表不能为空") def _get_and_validate_events(self, event_ids: list[int]) -> list[dict]: """批量查询事件详情并验证它们存在""" events = self.event_repo.get_events_by_ids(event_ids) if not events: raise HTTPException(status_code=404, detail="未找到任何事件") found_event_ids = {e["id"] for e in events} missing_event_ids = set(event_ids) - found_event_ids if missing_event_ids: raise HTTPException( status_code=404, detail=f"以下事件不存在: {sorted(missing_event_ids)}" ) return events def _validate_events_ended(self, events: list[dict]) -> None: """验证所有事件都已结束(有end_time)""" unended_events = [e for e in events if not e.get("end_time")] if unended_events: unended_ids = [e["id"] for e in unended_events] raise HTTPException( status_code=400, detail=f"以下事件尚未结束,无法聚合: {sorted(unended_ids)}", ) def _validate_events_not_linked(self, events: list[dict]) -> None: """检查是否有事件已关联到其他活动""" already_linked_events = [] for event in events: if self.activity_repo.activity_exists_for_event_id(event["id"]): already_linked_events.append(event["id"]) if already_linked_events: raise HTTPException( status_code=400, detail=f"以下事件已关联到其他活动: {sorted(already_linked_events)}", ) def _calculate_activity_time_range(self, events: list[dict]) -> tuple[datetime, datetime]: """计算活动时间范围""" start_times = [e["start_time"] for e in events if e.get("start_time")] end_times = [e["end_time"] for e in events if e.get("end_time")] if not start_times or not end_times: raise HTTPException(status_code=400, detail="无法计算活动时间范围") return min(start_times), max(end_times) def _prepare_events_data(self, events: list[dict]) -> list[dict]: """准备事件数据用于生成摘要""" return [ { "ai_title": event.get("ai_title") or "", "ai_summary": event.get("ai_summary") or "", "start_time": event.get("start_time"), } for event in events ] def _create_activity_with_summary( self, events_data: list[dict], activity_start_time: datetime, activity_end_time: datetime, event_ids: list[int], ) -> dict: """生成活动摘要并创建活动""" # 延迟导入避免循环依赖 summary_module = importlib.import_module("lifetrace.llm.activity_summary_service") result = summary_module.activity_summary_service.generate_activity_summary( events=events_data, start_time=activity_start_time, end_time=activity_end_time, ) if not result: raise HTTPException(status_code=500, detail="生成活动摘要失败") activity_id = self.activity_repo.create_activity( start_time=activity_start_time, end_time=activity_end_time, ai_title=result["title"], ai_summary=result["summary"], event_ids=event_ids, ) if not activity_id: raise HTTPException(status_code=500, detail="创建活动失败") created_activity = self.activity_repo.get_by_id(activity_id) if not created_activity: raise HTTPException(status_code=500, detail="获取创建的活动信息失败") logger.info( f"成功手动创建活动 {activity_id}: {result['title']},包含 {len(event_ids)} 个事件" ) return created_activity ================================================ FILE: lifetrace/services/asr_client.py ================================================ """阿里云Fun-ASR实时语音识别客户端 参考LLM客户端的实现方式,提供单例模式的ASR客户端。 """ import asyncio import json import uuid from collections.abc import Callable from typing import Any import websockets from websockets import protocol from websockets.exceptions import ConnectionClosed from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() class ASRClient: """阿里云Fun-ASR实时语音识别客户端(单例模式)""" _instance = None _initialized = False def __new__(cls): """实现单例模式""" if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self): """初始化ASR客户端""" if not ASRClient._initialized: self._initialize_client() ASRClient._initialized = True def _initialize_client(self): """内部方法:初始化或重新初始化客户端""" try: self.api_key = settings.audio.asr.api_key self.base_url = settings.audio.asr.base_url self.model = settings.audio.asr.model self.sample_rate = settings.audio.asr.sample_rate self.format = settings.audio.asr.format self.semantic_punctuation_enabled = settings.audio.asr.semantic_punctuation_enabled self.max_sentence_silence = settings.audio.asr.max_sentence_silence self.heartbeat = settings.audio.asr.heartbeat invalid_values = ["xxx", "YOUR_API_KEY_HERE", "YOUR_ASR_KEY_HERE"] if not self.api_key or self.api_key in invalid_values: logger.warning("ASR API Key未配置或为默认占位符,ASR功能可能不可用") except Exception as e: logger.error(f"无法从配置文件读取ASR配置: {e}") self.api_key = "YOUR_ASR_KEY_HERE" self.base_url = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/" self.model = "fun-asr-realtime" self.sample_rate = 16000 self.format = "pcm" self.semantic_punctuation_enabled = False self.max_sentence_silence = 1300 self.heartbeat = False logger.warning("使用硬编码默认值初始化ASR客户端") def reinitialize(self): """重新初始化ASR客户端(用于配置热重载)""" self._initialize_client() logger.info("ASR客户端已重新初始化") def _build_run_task_message(self, task_id: str) -> dict[str, Any]: """构建run-task消息""" return { "header": { "action": "run-task", "task_id": task_id, "streaming": "duplex", }, "payload": { "task_group": "audio", "task": "asr", "function": "recognition", "model": self.model, "parameters": { "format": self.format, "sample_rate": self.sample_rate, "semantic_punctuation_enabled": self.semantic_punctuation_enabled, "max_sentence_silence": self.max_sentence_silence, "heartbeat": self.heartbeat, }, "input": {}, }, } def _build_finish_task_message(self, task_id: str) -> dict[str, Any]: """构建finish-task消息""" return { "header": { "action": "finish-task", "task_id": task_id, "streaming": "duplex", }, "payload": {"input": {}}, } def _handle_asr_event( self, event: str, data: dict[str, Any], on_result: Callable[[str, bool], None], on_error: Callable[[Exception], None] | None, task_started_ref: list[bool], ) -> bool: """处理ASR事件,返回是否应该继续""" logger.debug(f"ASR event received: {event}, data keys: {list(data.keys())}") if event == "task-started": task_started_ref[0] = True logger.info("ASR任务已启动") return True if event == "result-generated": payload = data.get("payload", {}) output = payload.get("output", {}) sentence = output.get("sentence", {}) text = sentence.get("text", "") is_final = sentence.get("sentence_end", False) if text: logger.info(f"ASR partial result: {text} (final={is_final})") if text and on_result: on_result(text, is_final) return True if event == "task-finished": logger.info("ASR任务已完成") return False if event == "task-failed": error_code = data.get("header", {}).get("error_code", "") error_message = data.get("header", {}).get("error_message", "") error = Exception(f"ASR任务失败: {error_code} - {error_message}") if on_error: on_error(error) logger.error(f"ASR任务失败: {error_message}") return False return True async def _receive_messages( self, ws: Any, on_result: Callable[[str, bool], None], on_error: Callable[[Exception], None] | None, task_started_ref: list[bool], ) -> None: """接收并处理ASR消息""" async for message in ws: try: data = json.loads(message) event = data.get("header", {}).get("event") should_continue = self._handle_asr_event( event, data, on_result, on_error, task_started_ref ) if not should_continue: break except json.JSONDecodeError as e: logger.error(f"解析ASR响应失败: {e}") except Exception as e: logger.error(f"处理ASR响应时出错: {e}") if on_error: on_error(e) async def _send_audio( self, ws: Any, audio_stream: Any, task_id: str, task_started_ref: list[bool] ) -> None: """发送音频数据""" # 等待任务启动 max_wait_time = 5.0 # 最多等待5秒 wait_interval = 0.1 waited_time = 0.0 while not task_started_ref[0] and waited_time < max_wait_time: await asyncio.sleep(wait_interval) waited_time += wait_interval if not task_started_ref[0]: logger.warning("ASR task did not start within timeout, continuing anyway") try: async for chunk in audio_stream: # websockets库使用state属性检查连接状态 if ws.state == protocol.State.OPEN and chunk: await ws.send(chunk) elif ws.state in (protocol.State.CLOSED, protocol.State.CLOSING): logger.info("WebSocket closed, stopping audio stream") break except Exception as e: logger.error(f"Error sending audio stream: {e}") # 发送finish-task指令 finish_task_message = self._build_finish_task_message(task_id) if ws.state == protocol.State.OPEN: try: await ws.send(json.dumps(finish_task_message)) logger.info("Sent finish-task message") except Exception as e: logger.error(f"Failed to send finish-task message: {e}") async def transcribe_stream( self, audio_stream: Any, on_result: Callable[[str, bool], None], on_error: Callable[[Exception], None] | None = None, ) -> None: """实时语音识别流式转录 Args: audio_stream: 音频数据流(二进制) on_result: 识别结果回调函数,接收 (text: str, is_final: bool) 参数 on_error: 错误回调函数,接收 (error: Exception) 参数 """ task_id = uuid.uuid4().hex[:32] # 生成32位随机ID # websockets库的additional_headers需要使用列表格式,每个元素是(name, value)元组 headers = [ ("Authorization", f"Bearer {self.api_key}"), ] try: # 检查API Key是否配置 invalid_values = ["xxx", "YOUR_API_KEY_HERE", "YOUR_ASR_KEY_HERE"] if not self.api_key or self.api_key in invalid_values: error_msg = "ASR API Key未配置,请先配置API Key" logger.error(error_msg) if on_error: on_error(Exception(error_msg)) return # websockets库使用additional_headers参数(接受列表格式) async with websockets.connect(self.base_url, additional_headers=headers) as ws: logger.info("Connected to ASR WebSocket") # 发送run-task指令 run_task_message = self._build_run_task_message(task_id) await ws.send(json.dumps(run_task_message)) logger.info("Sent run-task message") task_started_ref: list[bool] = [False] # 并发执行接收和发送 await asyncio.gather( self._receive_messages(ws, on_result, on_error, task_started_ref), self._send_audio(ws, audio_stream, task_id, task_started_ref), ) except ConnectionClosed: logger.info("ASR WebSocket连接已关闭") except Exception as e: logger.error(f"ASR转录失败: {e}", exc_info=True) if on_error: on_error(e) ================================================ FILE: lifetrace/services/asr_client_dashscope.py ================================================ """阿里云Fun-ASR实时语音识别客户端(使用DashScope SDK) 使用dashscope SDK进行实时语音识别,支持麦克风输入和本地音频文件。 """ from collections.abc import Callable import dashscope from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() class ASRDashScopeClient: """阿里云Fun-ASR实时语音识别客户端(使用DashScope SDK)""" _instance = None _initialized = False def __new__(cls): """实现单例模式""" if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self): """初始化ASR客户端""" if not ASRDashScopeClient._initialized: self._initialize_client() ASRDashScopeClient._initialized = True def _initialize_client(self): """内部方法:初始化或重新初始化客户端""" try: self.api_key = settings.audio.asr.api_key self.base_url = settings.audio.asr.base_url self.model = settings.audio.asr.model self.sample_rate = settings.audio.asr.sample_rate self.format = settings.audio.asr.format self.semantic_punctuation_enabled = settings.audio.asr.semantic_punctuation_enabled self.max_sentence_silence = settings.audio.asr.max_sentence_silence self.heartbeat = settings.audio.asr.heartbeat # 配置dashscope dashscope.api_key = self.api_key dashscope.base_websocket_api_url = self.base_url invalid_values = ["xxx", "YOUR_API_KEY_HERE", "YOUR_ASR_KEY_HERE"] if not self.api_key or self.api_key in invalid_values: logger.warning("ASR API Key未配置或为默认占位符,ASR功能可能不可用") except Exception as e: logger.error(f"无法从配置文件读取ASR配置: {e}") self.api_key = "YOUR_ASR_KEY_HERE" self.base_url = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/" self.model = "fun-asr-realtime" self.sample_rate = 16000 self.format = "pcm" self.semantic_punctuation_enabled = False self.max_sentence_silence = 1300 self.heartbeat = False logger.warning("使用硬编码默认值初始化ASR客户端") def reinitialize(self): """重新初始化ASR客户端(用于配置热重载)""" self._initialize_client() logger.info("ASR客户端已重新初始化") def create_recognition_callback( self, on_result: Callable[[str, bool], None], on_error: Callable[[Exception], None] | None = None, ) -> RecognitionCallback: """创建识别回调 Args: on_result: 识别结果回调,接收 (text: str, is_final: bool) 参数 on_error: 错误回调,接收 (error: Exception) 参数 """ class Callback(RecognitionCallback): def __init__(self, result_cb, error_cb): self.result_cb = result_cb self.error_cb = error_cb def on_open(self) -> None: logger.info("ASR识别已启动") def on_close(self) -> None: logger.info("ASR识别已关闭") def on_complete(self) -> None: logger.info("ASR识别已完成") def on_error(self, result: RecognitionResult) -> None: error_msg = f"ASR识别错误: {result.message}" logger.error(error_msg) if self.error_cb: self.error_cb(Exception(error_msg)) def on_event(self, result: RecognitionResult) -> None: sentence = result.get_sentence() if isinstance(sentence, dict) and "text" in sentence: text = str(sentence.get("text", "")) is_final = RecognitionResult.is_sentence_end(sentence) if text and self.result_cb: self.result_cb(text, is_final) return Callback(on_result, on_error) def create_recognition(self, callback: RecognitionCallback) -> Recognition: """创建识别实例 Args: callback: 识别回调 Returns: Recognition实例 """ return Recognition( model=self.model, format=self.format, sample_rate=self.sample_rate, semantic_punctuation_enabled=self.semantic_punctuation_enabled, callback=callback, ) ================================================ FILE: lifetrace/services/audio_extraction_service.py ================================================ """音频提取服务 处理音频转录文本的待办和日程提取逻辑。 """ import hashlib import json from typing import Any from sqlmodel import select from lifetrace.llm.llm_client import LLMClient from lifetrace.storage import get_session from lifetrace.storage.models import Transcription from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt logger = get_logger() class AudioExtractionService: """音频提取服务""" def __init__(self, llm_client: LLMClient): """初始化提取服务 Args: llm_client: LLM客户端 """ self.llm_client = llm_client def _stable_extracted_id(self, prefix: str, item: dict) -> str: """生成稳定的提取项ID Args: prefix: 前缀 item: 提取项字典 Returns: 稳定的ID字符串 """ base = "|".join( [ str(item.get("source_text") or ""), str(item.get("start_time") or item.get("deadline") or item.get("time") or ""), ] ) digest = hashlib.sha1(base.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] return f"{prefix}_{digest}" def _enrich_extracted_items(self, prefix: str, items: list[dict]) -> list[dict]: """丰富提取项,添加缺失字段 Args: prefix: 前缀 items: 提取项列表 Returns: 丰富后的提取项列表 """ out: list[dict] = [] for it in items: if not isinstance(it, dict): continue it2 = dict(it) it2.setdefault( "dedupe_key", "|".join( [ str(it2.get("source_text") or ""), str(it2.get("start_time") or it2.get("deadline") or it2.get("time") or ""), ] ), ) it2.setdefault("id", self._stable_extracted_id(prefix, it2)) it2.setdefault("linked", False) it2.setdefault("linked_todo_id", None) out.append(it2) return out def update_extraction( self, transcription_id: int, todos: list[dict] | None = None, schedules: list[dict] | None = None, optimized: bool = False, ) -> Transcription | None: """更新提取结果 Args: transcription_id: 转录ID todos: 待办事项列表 schedules: 日程安排列表 optimized: 是否为优化文本的提取结果 Returns: 更新后的Transcription对象 """ with get_session() as session: transcription = session.get(Transcription, transcription_id) if transcription: if optimized: if todos is not None: transcription.extracted_todos_optimized = json.dumps( self._enrich_extracted_items("todo", todos), ensure_ascii=False ) if schedules is not None: transcription.extracted_schedules_optimized = json.dumps( self._enrich_extracted_items("schedule", schedules), ensure_ascii=False ) else: if todos is not None: transcription.extracted_todos = json.dumps( self._enrich_extracted_items("todo", todos), ensure_ascii=False ) if schedules is not None: transcription.extracted_schedules = json.dumps( self._enrich_extracted_items("schedule", schedules), ensure_ascii=False ) transcription.extraction_status = "completed" session.commit() session.refresh(transcription) return transcription def _load_extraction_from_transcription( self, transcription: Transcription, optimized: bool ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """从转录对象中加载提取结果 Args: transcription: 转录对象 optimized: 是否加载优化文本的提取结果 Returns: (todos, schedules) 元组 """ todos: list[dict[str, Any]] = [] schedules: list[dict[str, Any]] = [] if optimized: if transcription.extracted_todos_optimized: try: todos = json.loads(transcription.extracted_todos_optimized) except Exception: todos = [] if transcription.extracted_schedules_optimized: try: schedules = json.loads(transcription.extracted_schedules_optimized) except Exception: schedules = [] else: if transcription.extracted_todos: try: todos = json.loads(transcription.extracted_todos) except Exception: todos = [] if transcription.extracted_schedules: try: schedules = json.loads(transcription.extracted_schedules) except Exception: schedules = [] return todos, schedules def _build_item_lookup_maps( self, items: list[dict[str, Any]] ) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]: """构建项目的查找映射(按 id 和 dedupe_key) Args: items: 项目列表 Returns: (by_id, by_dedupe) 元组 """ by_id: dict[str, dict[str, Any]] = {} by_dedupe: dict[str, dict[str, Any]] = {} for item in items: if not isinstance(item, dict): continue item_id = item.get("id") dedupe_key = item.get("dedupe_key") if item_id: by_id[str(item_id)] = item if dedupe_key: by_dedupe[str(dedupe_key)] = item return by_id, by_dedupe def _apply_links_to_items( self, links: list[dict[str, Any]], todo_by_id: dict[str, dict[str, Any]], todo_by_dedupe: dict[str, dict[str, Any]], sched_by_id: dict[str, dict[str, Any]], sched_by_dedupe: dict[str, dict[str, Any]], ) -> int: """应用链接到项目 Args: links: 链接列表 todo_by_id: 待办按 id 的映射 todo_by_dedupe: 待办按 dedupe_key 的映射 sched_by_id: 日程按 id 的映射 sched_by_dedupe: 日程按 dedupe_key 的映射 Returns: 更新的项目数量 """ updated = 0 for link in links: kind = link.get("kind") item_id = link.get("item_id") todo_id = link.get("todo_id") if not kind or not item_id or not todo_id: continue if kind == "todo": target = todo_by_id.get(item_id) or todo_by_dedupe.get(item_id) else: target = sched_by_id.get(item_id) or sched_by_dedupe.get(item_id) if not target: continue target["linked"] = True target["linked_todo_id"] = int(todo_id) updated += 1 return updated def link_extracted_items( self, recording_id: int, links: list[dict[str, Any]], optimized: bool = False, ) -> dict[str, Any]: """标记提取项为已链接到待办(持久化在转录JSON中) Args: recording_id: 录音ID links: 链接列表 optimized: 是否更新优化文本的提取结果 Returns: 包含更新数量的字典 """ with get_session() as session: # 查询转录记录(一个 recording_id 只应该有一条) statement = ( select(Transcription) .where(Transcription.audio_recording_id == recording_id) .order_by(col(Transcription.id).desc()) ) transcription = session.exec(statement).first() if not transcription: raise ValueError("transcription not found") todos, schedules = self._load_extraction_from_transcription(transcription, optimized) # Backfill missing fields for legacy stored items (and persist) todos = self._enrich_extracted_items("todo", todos) schedules = self._enrich_extracted_items("schedule", schedules) todo_by_id, todo_by_dedupe = self._build_item_lookup_maps(todos) sched_by_id, sched_by_dedupe = self._build_item_lookup_maps(schedules) updated = self._apply_links_to_items( links, todo_by_id, todo_by_dedupe, sched_by_id, sched_by_dedupe ) if optimized: transcription.extracted_todos_optimized = json.dumps(todos, ensure_ascii=False) transcription.extracted_schedules_optimized = json.dumps( schedules, ensure_ascii=False ) else: transcription.extracted_todos = json.dumps(todos, ensure_ascii=False) transcription.extracted_schedules = json.dumps(schedules, ensure_ascii=False) session.add(transcription) session.commit() return {"updated": updated} def _load_extraction_prompts(self, text: str) -> tuple[str, str]: """加载提取提示词 Args: text: 转录文本 Returns: (system_prompt, user_prompt) 元组 """ system_prompt = get_prompt("transcription_extraction", "system_assistant") user_prompt = get_prompt("transcription_extraction", "user_prompt", text=text) if not system_prompt or not user_prompt: logger.warning("无法加载提取提示词,使用默认提示词") system_prompt = "你是一个专业的任务和日程提取助手。" user_prompt = f"请从以下转录文本中提取待办事项和日程安排。\n\n转录文本:\n{text}\n\n只返回JSON,不要其他内容。" return system_prompt, user_prompt def _parse_llm_response(self, result_text: str) -> dict[str, Any]: """解析 LLM 响应文本 Args: result_text: LLM 返回的原始文本 Returns: 解析后的 JSON 字典 """ # 移除可能的markdown代码块标记 if result_text.startswith("```json"): result_text = result_text[7:] if result_text.startswith("```"): result_text = result_text[3:] if result_text.endswith("```"): result_text = result_text[:-3] result_text = result_text.strip() return json.loads(result_text) def _normalize_extraction_result(self, result: dict[str, Any]) -> dict[str, Any]: """规范化提取结果格式 将字符串数组转换为标准格式(对象数组,包含 source_text) Args: result: 原始提取结果 Returns: 规范化后的结果 """ # 处理 todos if "todos" in result: todos = result["todos"] if todos and isinstance(todos[0], str): result["todos"] = [ { "title": item, "description": None, "source_text": item, } for item in todos ] # 处理 schedules if "schedules" in result: schedules = result["schedules"] if schedules and isinstance(schedules[0], str): result["schedules"] = [ { "title": item, "time": None, "description": None, "source_text": item, } for item in schedules ] return result async def extract_todos_and_schedules(self, text: str) -> dict[str, Any]: """从转录文本中提取待办和日程 Args: text: 转录文本 Returns: 包含todos和schedules的字典 """ try: if not self.llm_client.is_available(): logger.warning("LLM客户端不可用,跳过提取") return {"todos": [], "schedules": []} # 加载提示词 system_prompt, user_prompt = self._load_extraction_prompts(text) # 调用 LLM client = self.llm_client client._initialize_client() openai_client = client._get_client() response = openai_client.chat.completions.create( model=client.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0.3, ) # 解析响应 result_text = (response.choices[0].message.content or "").strip() result = self._parse_llm_response(result_text) # 规范化结果格式 result = self._normalize_extraction_result(result) return result except Exception as e: logger.error(f"提取待办和日程失败: {e}") return {"todos": [], "schedules": []} ================================================ FILE: lifetrace/services/audio_service.py ================================================ """音频服务层 处理音频录制、存储、转录等业务逻辑。 """ import asyncio import json from datetime import datetime from pathlib import Path from typing import Any from sqlmodel import select from lifetrace.llm.llm_client import LLMClient from lifetrace.services.audio_extraction_service import AudioExtractionService from lifetrace.storage import get_session from lifetrace.storage.models import AudioRecording, Transcription from lifetrace.storage.sql_utils import col from lifetrace.util.base_paths import get_user_data_dir from lifetrace.util.logging_config import get_logger from lifetrace.util.prompt_loader import get_prompt from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now, to_local logger = get_logger() class AudioService: """音频服务""" def __init__(self): """初始化音频服务""" self.llm_client = LLMClient() self.extraction_service = AudioExtractionService(self.llm_client) self._background_tasks: set[asyncio.Task] = set() self.audio_base_dir = Path(get_user_data_dir()) / settings.audio.storage.audio_dir self.temp_audio_dir = Path(get_user_data_dir()) / settings.audio.storage.temp_audio_dir self.audio_base_dir.mkdir(parents=True, exist_ok=True) self.temp_audio_dir.mkdir(parents=True, exist_ok=True) def get_audio_dir_for_date(self, date: datetime) -> Path: """获取指定日期的音频存储目录(按年月日组织) Args: date: 日期 Returns: 音频目录路径(格式:audio/2025/01/17/) """ year = date.strftime("%Y") month = date.strftime("%m") day = date.strftime("%d") audio_dir = self.audio_base_dir / year / month / day audio_dir.mkdir(parents=True, exist_ok=True) return audio_dir def generate_audio_file_path(self, date: datetime, filename: str | None = None) -> Path: """生成音频文件路径 Args: date: 日期 filename: 文件名(可选,如果不提供则自动生成) Returns: 音频文件路径 """ audio_dir = self.get_audio_dir_for_date(date) if filename: return audio_dir / filename # 自动生成文件名:HHMMSS.wav timestamp = date.strftime("%H%M%S") return audio_dir / f"{timestamp}.wav" def create_recording( self, file_path: str, file_size: int, duration: float, is_24x7: bool = False, ) -> int: """创建录音记录 Args: file_path: 音频文件路径 file_size: 文件大小(字节) duration: 录音时长(秒) is_24x7: 是否为7x24小时录制 Returns: 创建的AudioRecording对象 """ # 注意:不要把 ORM 实例(AudioRecording)跨 session 返回到路由层; # SQLAlchemy 默认会在 commit 后过期属性,session 关闭后再访问会触发 refresh, # 从而报 “Instance ... is not bound to a Session”。 # 这里只返回 recording_id,路由层需要对象时再用新的 session 查询。 with get_session() as session: recording = AudioRecording( file_path=file_path, file_size=file_size, duration=duration, # 使用本地时间记录,避免前端显示存在时区偏移 start_time=get_utc_now().astimezone(), status="recording", is_24x7=is_24x7, is_transcribed=False, is_extracted=False, is_summarized=False, is_full_audio=False, is_segment_audio=False, transcription_status="pending", ) session.add(recording) session.commit() session.refresh(recording) if recording.id is None: raise ValueError("Recording must have an id after creation.") return int(recording.id) def complete_recording(self, recording_id: int) -> AudioRecording | None: """完成录音 Args: recording_id: 录音ID Returns: 更新后的AudioRecording对象,如果不存在则返回None """ with get_session() as session: recording = session.get(AudioRecording, recording_id) if recording: recording.status = "completed" # 使用本地时间记录结束时间 recording.end_time = get_utc_now().astimezone() recording.transcription_status = "processing" session.commit() session.refresh(recording) return recording def get_recordings_by_date(self, date: datetime) -> list[dict[str, Any]]: """根据日期获取录音列表 Args: date: 日期 Returns: 录音列表(序列化后的字典列表,避免 Session 错误) """ with get_session() as session: start_of_day = date.replace(hour=0, minute=0, second=0, microsecond=0) end_of_day = date.replace(hour=23, minute=59, second=59, microsecond=999999) statement = select(AudioRecording).where( col(AudioRecording.start_time) >= start_of_day, col(AudioRecording.start_time) <= end_of_day, col(AudioRecording.deleted_at).is_(None), ) recordings = session.exec(statement).all() # 在 session 内序列化数据,避免 Session 错误 result = [] for rec in recordings: result.append( { "id": rec.id, "file_path": rec.file_path, "file_size": rec.file_size, "duration": rec.duration, "start_time": to_local(rec.start_time), "end_time": to_local(rec.end_time) if rec.end_time else None, "status": rec.status, "is_24x7": rec.is_24x7, "is_transcribed": rec.is_transcribed, "is_extracted": rec.is_extracted, "is_summarized": rec.is_summarized, "is_full_audio": rec.is_full_audio, "is_segment_audio": rec.is_segment_audio, "transcription_status": rec.transcription_status, } ) return result def _check_has_extraction(self, transcription: Transcription) -> bool: """检查转录记录是否有提取结果 Args: transcription: 转录记录 Returns: 是否有提取结果 """ return bool( ( transcription.extracted_todos and transcription.extracted_todos.strip() and transcription.extracted_todos.strip() != "[]" ) or ( transcription.extracted_schedules and transcription.extracted_schedules.strip() and transcription.extracted_schedules.strip() != "[]" ) or ( transcription.extracted_todos_optimized and transcription.extracted_todos_optimized.strip() and transcription.extracted_todos_optimized.strip() != "[]" ) or ( transcription.extracted_schedules_optimized and transcription.extracted_schedules_optimized.strip() and transcription.extracted_schedules_optimized.strip() != "[]" ) ) def _check_text_changes( self, existing: Transcription, segmented_text: str, optimized_text: str | None ) -> tuple[bool, bool]: """检查文本是否变化 Args: existing: 现有转录记录 segmented_text: 新的分段文本 optimized_text: 新的优化文本 Returns: (original_changed, optimized_changed) 元组 """ original_changed = (existing.original_text or "").strip() != (segmented_text or "").strip() optimized_changed = (existing.optimized_text or "").strip() != ( optimized_text or "" ).strip() return original_changed, optimized_changed def _cleanup_duplicate_transcriptions( self, session, recording_id: int, existing: Transcription ) -> Transcription: """清理重复的转录记录 Args: session: 数据库会话 recording_id: 录音ID existing: 现有记录 Returns: 保留的记录 """ all_records = list( session.exec( select(Transcription) .where(col(Transcription.audio_recording_id) == recording_id) .order_by(col(Transcription.id).desc()) ).all() ) if len(all_records) > 1: logger.warning( f"[save_transcription] 录音 {recording_id} 发现 {len(all_records)} 条转录记录," f"保留最新的(ID={all_records[0].id}),删除其他 {len(all_records) - 1} 条" ) # 保留第一条(ID最大的),删除其他的 for old_record in all_records[1:]: session.delete(old_record) existing = all_records[0] session.flush() return existing def _update_existing_transcription( self, session, existing: Transcription, recording_id: int, segmented_text: str, optimized_text: str | None, segment_timestamps_json: str | None = None, ) -> tuple[Transcription, bool]: """更新现有转录记录 Args: session: 数据库会话 existing: 现有记录 recording_id: 录音ID segmented_text: 分段文本 optimized_text: 优化文本 Returns: (transcription, should_auto_extract) 元组 """ original_changed, optimized_changed = self._check_text_changes( existing, segmented_text, optimized_text ) text_changed = original_changed or optimized_changed if not text_changed: logger.debug(f"[save_transcription] 录音 {recording_id} 文本未变化,跳过更新") return existing, False # 文本变化了,更新文本字段(保留提取结果) existing.original_text = segmented_text existing.optimized_text = optimized_text # 如果提供了新的时间戳,也更新 if segment_timestamps_json is not None: existing.segment_timestamps = segment_timestamps_json has_extraction = self._check_has_extraction(existing) should_auto_extract = False if not has_extraction: existing.extraction_status = "pending" should_auto_extract = True else: logger.info( f"[save_transcription] 录音 {recording_id} 文本变化但已有提取结果," f"保留提取结果,不触发自动提取" ) session.add(existing) return existing, should_auto_extract def _prepare_transcription_data( self, original_text: str, segment_timestamps: list[float] | None, recording_id: int, ) -> tuple[str, str | None]: """准备转录数据(处理文本和时间戳) Returns: (display_text, segment_timestamps_json) """ display_lines = [line.strip() for line in (original_text or "").split("\n") if line.strip()] display_text = "\n".join(display_lines) segment_timestamps_json = None if segment_timestamps is not None: # 严格一致:不做插值/均分/猜测。长度不一致就丢弃时间戳,前端回退到均匀估算。 if len(segment_timestamps) == len(display_lines): segment_timestamps_json = json.dumps(segment_timestamps, ensure_ascii=False) else: logger.warning( f"[save_transcription] segment_timestamps 行数不匹配,丢弃时间戳以避免错误跳转。" f" recording_id={recording_id}, timestamps={len(segment_timestamps)}, lines={len(display_lines)}" ) return display_text, segment_timestamps_json async def _optimize_text_if_needed(self, display_text: str, auto_optimize: bool) -> str | None: """如果需要,优化文本""" if not auto_optimize or not display_text: return None try: return await self.optimize_transcription_text(display_text) except Exception as e: logger.error(f"自动优化文本失败: {e}") return None def _create_or_update_transcription( self, session: Any, recording_id: int, display_text: str, optimized_text: str | None, segment_timestamps_json: str | None, ) -> tuple[Transcription, bool]: """创建或更新转录记录 Returns: (transcription, should_auto_extract) """ # 检查是否已存在转录记录 existing = session.exec( select(Transcription) .where(col(Transcription.audio_recording_id) == recording_id) .order_by(col(Transcription.id).desc()) ).first() # 清理重复记录 if existing: existing = self._cleanup_duplicate_transcriptions(session, recording_id, existing) # 更新或创建记录 if existing: transcription, should_auto_extract = self._update_existing_transcription( session, existing, recording_id, display_text, optimized_text, segment_timestamps_json, ) else: logger.info(f"[save_transcription] 录音 {recording_id} 创建新转录记录") transcription = Transcription( audio_recording_id=recording_id, original_text=display_text, optimized_text=optimized_text, extraction_status="pending", segment_timestamps=segment_timestamps_json, ) session.add(transcription) should_auto_extract = True return transcription, should_auto_extract def _update_recording_status(self, session: Any, recording_id: int) -> None: """更新录音记录的转录状态""" recording = session.get(AudioRecording, recording_id) if recording: recording.transcription_status = "completed" session.commit() def _trigger_auto_extraction( self, transcription_id: int, display_text: str, optimized_text: str | None ) -> None: """触发自动提取待办和日程(异步执行,不阻塞)""" if display_text: task = asyncio.create_task( self._auto_extract_todos_and_schedules( transcription_id, display_text, optimized=False ) ) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) if optimized_text: task = asyncio.create_task( self._auto_extract_todos_and_schedules( transcription_id, optimized_text, optimized=True ) ) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) async def save_transcription( self, recording_id: int, original_text: str, auto_optimize: bool = True, segment_timestamps: list[float] | None = None, ) -> Transcription: """保存转录文本(自动优化和提取) Args: recording_id: 录音ID original_text: 原始转录文本(前端展示用文本,final 一句一行) auto_optimize: 是否自动优化文本 segment_timestamps: 每段文本的精确时间戳(秒),相对于录音开始时间 Returns: 创建的Transcription对象 """ # 准备数据 display_text, segment_timestamps_json = self._prepare_transcription_data( original_text, segment_timestamps, recording_id ) # 优化文本 optimized_text = await self._optimize_text_if_needed(display_text, auto_optimize) with get_session() as session: # 创建或更新转录记录 transcription, should_auto_extract = self._create_or_update_transcription( session, recording_id, display_text, optimized_text, segment_timestamps_json ) session.commit() session.refresh(transcription) # 更新录音记录的转录状态 self._update_recording_status(session, recording_id) # 自动提取待办和日程(异步执行,不阻塞) if should_auto_extract: if transcription.id is None: raise ValueError("Transcription must have an id before extraction.") self._trigger_auto_extraction(transcription.id, display_text, optimized_text) return transcription async def _auto_extract_todos_and_schedules( self, transcription_id: int, text: str, optimized: bool = False ) -> None: """自动提取待办和日程(后台任务) Args: transcription_id: 转录ID text: 要提取的文本 optimized: 是否为优化文本的提取 """ try: result = await self.extraction_service.extract_todos_and_schedules(text) self.extraction_service.update_extraction( transcription_id=transcription_id, todos=result.get("todos", []), schedules=result.get("schedules", []), optimized=optimized, ) except Exception as e: logger.error(f"自动提取待办和日程失败 (optimized={optimized}): {e}") async def optimize_transcription_text(self, text: str) -> str: """使用LLM优化转录文本 Args: text: 原始转录文本 Returns: 优化后的文本 """ try: if not self.llm_client.is_available(): logger.warning("LLM客户端不可用,跳过文本优化") return text # 从配置文件加载提示词 system_prompt = get_prompt("transcription_optimization", "system_assistant") user_prompt = get_prompt("transcription_optimization", "user_prompt", text=text) if not system_prompt or not user_prompt: logger.warning("无法加载优化提示词,使用默认提示词") system_prompt = "你是一个专业的文本优化助手,擅长优化语音转录文本。" user_prompt = f"请优化以下语音转录文本,使其更加流畅、准确、易读。\n\n转录文本:\n{text}\n\n只返回优化后的文本,不要其他内容。" client = self.llm_client client._initialize_client() openai_client = client._get_client() response = openai_client.chat.completions.create( model=client.model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0.3, ) optimized_text = (response.choices[0].message.content or "").strip() # 移除可能的markdown代码块标记 if optimized_text.startswith("```"): lines = optimized_text.split("\n") if lines[0].startswith("```"): min_lines_for_code_block = 2 if len(lines) > min_lines_for_code_block: optimized_text = "\n".join(lines[1:-1]) optimized_text = optimized_text.strip() return optimized_text except Exception as e: logger.error(f"优化转录文本失败: {e}") return text @property def extract_todos_and_schedules(self): """委托给 extraction_service""" return self.extraction_service.extract_todos_and_schedules @property def update_extraction(self): """委托给 extraction_service""" return self.extraction_service.update_extraction @property def link_extracted_items(self): """委托给 extraction_service""" return self.extraction_service.link_extracted_items def get_transcription(self, recording_id: int) -> dict[str, Any] | None: """获取转录文本(已序列化) 注意:不要将 ORM 实例返回到路由层,避免 Session 关闭后访问属性时报 “Instance is not bound to a Session”。 Args: recording_id: 录音ID Returns: 包含转录字段的字典,如果不存在则返回None """ with get_session() as session: # 查询转录记录(一个 recording_id 只应该有一条) statement = ( select(Transcription) .where(col(Transcription.audio_recording_id) == recording_id) .order_by(col(Transcription.id).desc()) ) transcription = session.exec(statement).first() if not transcription: return None return { "id": transcription.id, "audio_recording_id": transcription.audio_recording_id, "original_text": transcription.original_text, "optimized_text": transcription.optimized_text, "extracted_todos": transcription.extracted_todos, "extracted_schedules": transcription.extracted_schedules, "extracted_todos_optimized": transcription.extracted_todos_optimized, "extracted_schedules_optimized": transcription.extracted_schedules_optimized, "extraction_status": transcription.extraction_status, "segment_timestamps": transcription.segment_timestamps, "created_at": transcription.created_at, "updated_at": transcription.updated_at, } ================================================ FILE: lifetrace/services/automation_task_service.py ================================================ """自动化任务服务 - 负责调度同步与执行""" from __future__ import annotations import json import urllib.request from datetime import datetime from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from zoneinfo import ZoneInfo from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.interval import IntervalTrigger from lifetrace.jobs.scheduler import get_scheduler_manager from lifetrace.storage import automation_task_mgr from lifetrace.storage.notification_storage import add_notification from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now, naive_as_utc logger = get_logger() TASK_JOB_PREFIX = "automation_task_" DEFAULT_FETCH_TIMEOUT = 10 DEFAULT_FETCH_MAX_CHARS = 2000 if TYPE_CHECKING: from lifetrace.schemas.automation import AutomationAction, AutomationSchedule class AutomationTaskService: """自动化任务调度与执行服务""" def list_tasks(self) -> list[dict[str, Any]]: tasks = automation_task_mgr.list_tasks() return [self._hydrate_task(task) for task in tasks] def get_task(self, task_id: int) -> dict[str, Any] | None: task = automation_task_mgr.get_task(task_id) if not task: return None return self._hydrate_task(task) def create_task( self, *, name: str, description: str | None, enabled: bool, schedule: AutomationSchedule, action: AutomationAction, ) -> dict[str, Any] | None: schedule_type, schedule_config = self._serialize_schedule(schedule) action_type, action_payload = self._serialize_action(action) task_id = automation_task_mgr.create_task( name=name, description=description, enabled=enabled, schedule_type=schedule_type, schedule_config=schedule_config, action_type=action_type, action_payload=action_payload, ) if task_id is None: return None task = automation_task_mgr.get_task(task_id) if not task: return None self.sync_task(task) return self._hydrate_task(task) def update_task( self, task_id: int, *, name: str | None, description: str | None, enabled: bool | None, schedule: AutomationSchedule | None, action: AutomationAction | None, ) -> dict[str, Any] | None: updates: dict[str, Any] = {} if name is not None: updates["name"] = name if description is not None: updates["description"] = description if enabled is not None: updates["enabled"] = enabled if schedule is not None: schedule_type, schedule_config = self._serialize_schedule(schedule) updates["schedule_type"] = schedule_type updates["schedule_config"] = schedule_config if action is not None: action_type, action_payload = self._serialize_action(action) updates["action_type"] = action_type updates["action_payload"] = action_payload success = automation_task_mgr.update_task(task_id, **updates) if not success: return None task = automation_task_mgr.get_task(task_id) if not task: return None self.sync_task(task) return self._hydrate_task(task) def delete_task(self, task_id: int) -> bool: removed = automation_task_mgr.delete_task(task_id) if removed: self._remove_job(task_id) return removed def run_task(self, task_id: int) -> bool: task = automation_task_mgr.get_task(task_id) if not task: return False return self._execute_task(task) def sync_all_tasks(self) -> None: tasks = automation_task_mgr.list_tasks() for task in tasks: self.sync_task(task) def sync_task(self, task: dict[str, Any]) -> None: if not task.get("id"): return if not task.get("enabled", False): self._remove_job(task["id"]) return scheduler = get_scheduler_manager() if not scheduler or not scheduler.scheduler: logger.warning("调度器未就绪,无法同步自动化任务") return try: trigger = self._build_trigger(task) except ValueError as exc: logger.error("自动化任务调度配置无效: %s", exc) return scheduler.scheduler.add_job( execute_automation_task, trigger=trigger, id=self._job_id(task["id"]), name=task.get("name") or self._job_id(task["id"]), replace_existing=True, kwargs={"task_id": task["id"]}, ) def _execute_task(self, task: dict[str, Any]) -> bool: if not task.get("enabled", False): return False now = get_utc_now() last_status = "success" last_error: str | None = None last_output: str | None = None try: action_type = task.get("action_type") or "" payload = self._parse_payload(task.get("action_payload")) last_output = self._run_action(action_type, payload) except Exception as exc: last_status = "error" last_error = str(exc) logger.error("自动化任务执行失败: %s", exc, exc_info=True) automation_task_mgr.update_task( task["id"], last_run_at=now, last_status=last_status, last_error=last_error, last_output=last_output, ) self._notify_task_result(task, last_status, last_error, last_output, now) if task.get("schedule_type") == "once": automation_task_mgr.update_task(task["id"], enabled=False) self._remove_job(task["id"]) return last_status == "success" def _notify_task_result( self, task: dict[str, Any], status: str, error: str | None, output: str | None, timestamp: datetime, ) -> None: content = output or "" if status != "success": content = error or "执行失败" if content: content = content[:DEFAULT_FETCH_MAX_CHARS] title = f"自动化任务: {task.get('name', task.get('id'))}" notification_id = f"automation_{task.get('id')}_{int(timestamp.timestamp())}" add_notification( notification_id=notification_id, title=title, content=content or ("执行成功" if status == "success" else "执行失败"), timestamp=timestamp, ) def _run_action(self, action_type: str, payload: dict[str, Any]) -> str: if action_type == "web_fetch": return self._run_web_fetch(payload) raise ValueError(f"未知的自动化动作类型: {action_type}") def _run_web_fetch(self, payload: dict[str, Any]) -> str: url = payload.get("url") if not url: raise ValueError("web_fetch 需要提供 url") parsed_url = urlparse(str(url)) if parsed_url.scheme not in ("http", "https"): raise ValueError("web_fetch 仅支持 http/https 协议") method = str(payload.get("method") or "GET").upper() timeout = int(payload.get("timeout_seconds") or DEFAULT_FETCH_TIMEOUT) max_chars = int(payload.get("max_chars") or DEFAULT_FETCH_MAX_CHARS) headers = payload.get("headers") if not isinstance(headers, dict): headers = {} body = payload.get("body") data = body.encode("utf-8") if isinstance(body, str) else None request = urllib.request.Request(url, data=data, method=method) for key, value in headers.items(): request.add_header(str(key), str(value)) with urllib.request.urlopen(request, timeout=timeout) as response: # nosec B310 raw = response.read() text = raw.decode("utf-8", errors="replace") preview = text[:max_chars] status = response.status content_type = response.headers.get("content-type", "") return f"[{status}] {content_type}\n{preview}" def _serialize_schedule(self, schedule: AutomationSchedule) -> tuple[str, str]: schedule_type = schedule.type config = schedule.dict(exclude_none=True) config.pop("type", None) run_at = config.get("run_at") if isinstance(run_at, datetime): config["run_at"] = run_at.isoformat() self._validate_schedule(schedule_type, config) return schedule_type, json.dumps(config) def _serialize_action(self, action: AutomationAction) -> tuple[str, str]: action_type = action.type payload = action.payload or {} return action_type, json.dumps(payload) def _validate_schedule(self, schedule_type: str, config: dict[str, Any]) -> None: if schedule_type == "interval": if not config.get("interval_seconds"): raise ValueError("interval 类型必须提供 interval_seconds") return if schedule_type == "cron": if not config.get("cron"): raise ValueError("cron 类型必须提供 cron 表达式") return if schedule_type == "once": if not config.get("run_at"): raise ValueError("once 类型必须提供 run_at") return raise ValueError(f"不支持的 schedule_type: {schedule_type}") def _build_trigger(self, task: dict[str, Any]): schedule_type = task.get("schedule_type") or "" config = self._parse_payload(task.get("schedule_config")) timezone = self._get_timezone(config.get("timezone")) if schedule_type == "interval": seconds = int(config.get("interval_seconds") or 0) if seconds <= 0: raise ValueError("interval_seconds 必须大于 0") return IntervalTrigger(seconds=seconds, timezone=timezone) if schedule_type == "cron": cron_expr = str(config.get("cron") or "").strip() if not cron_expr: raise ValueError("cron 表达式不能为空") return CronTrigger.from_crontab(cron_expr, timezone=timezone) if schedule_type == "once": run_at_raw = config.get("run_at") run_at = self._parse_datetime(run_at_raw) if not run_at: raise ValueError("run_at 无效") return DateTrigger(run_date=run_at, timezone=timezone) raise ValueError(f"不支持的 schedule_type: {schedule_type}") def _remove_job(self, task_id: int) -> None: scheduler = get_scheduler_manager() if not scheduler: return scheduler.remove_job(self._job_id(task_id)) @staticmethod def _job_id(task_id: int) -> str: return f"{TASK_JOB_PREFIX}{task_id}" @staticmethod def _parse_payload(value: Any) -> dict[str, Any]: if value is None: return {} if isinstance(value, dict): return value if isinstance(value, str): try: parsed = json.loads(value) if isinstance(parsed, dict): return parsed except json.JSONDecodeError: return {} return {} @staticmethod def _parse_datetime(value: Any) -> datetime | None: if isinstance(value, datetime): return naive_as_utc(value) if isinstance(value, str): try: normalized = value.replace("Z", "+00:00") parsed = datetime.fromisoformat(normalized) except ValueError: return None return naive_as_utc(parsed) return None @staticmethod def _get_timezone(value: Any): if not value: return None try: return ZoneInfo(str(value)) except Exception: return None def _hydrate_task(self, task: dict[str, Any]) -> dict[str, Any]: schedule_config = self._parse_payload(task.get("schedule_config")) schedule = { "type": task.get("schedule_type") or "", **schedule_config, } action_payload = self._parse_payload(task.get("action_payload")) action = { "type": task.get("action_type") or "", "payload": action_payload, } task["schedule"] = schedule task["action"] = action return task def execute_automation_task(task_id: int) -> None: """调度器入口函数:执行指定自动化任务""" service = AutomationTaskService() service.run_task(task_id) ================================================ FILE: lifetrace/services/chat_service.py ================================================ """Chat 业务逻辑层 处理 Chat 相关的业务逻辑,包含会话管理和消息处理。 会话上下文存储在数据库中,不再使用内存存储。 """ import json import uuid from typing import Any from lifetrace.repositories.interfaces import IChatRepository from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 会话上下文的最大消息数量 MAX_CONTEXT_LENGTH = 50 class ChatService: """Chat 业务逻辑层""" def __init__(self, repository: IChatRepository): self.repository = repository # ===== 会话 ID 生成 ===== @staticmethod def generate_session_id() -> str: """生成新的会话ID""" return str(uuid.uuid4()) # ===== 会话上下文管理(数据库存储) ===== def create_new_session(self, session_id: str | None = None) -> str: """创建新的聊天会话 Args: session_id: 可选的会话ID,如果不提供则自动生成 Returns: 会话ID """ if not session_id: session_id = self.generate_session_id() # 确保会话在数据库中存在 self.ensure_chat_exists(session_id, chat_type="general") # 初始化空上下文 self.repository.update_chat_context(session_id, json.dumps([])) logger.info(f"创建新会话: {session_id}") return session_id def clear_session_context(self, session_id: str) -> bool: """清除会话上下文 Args: session_id: 会话ID Returns: 是否清除成功 """ result = self.repository.update_chat_context(session_id, json.dumps([])) if result: logger.info(f"清除会话上下文: {session_id}") return result def get_session_context(self, session_id: str) -> list[dict[str, Any]]: """获取会话上下文 Args: session_id: 会话ID Returns: 上下文消息列表 """ context_json = self.repository.get_chat_context(session_id) if context_json: try: return json.loads(context_json) except json.JSONDecodeError: logger.warning(f"会话上下文 JSON 解析失败: {session_id}") return [] return [] def add_to_session_context(self, session_id: str, role: str, content: str): """添加消息到会话上下文 Args: session_id: 会话ID role: 消息角色(user, assistant, system) content: 消息内容 """ # 获取当前上下文 context = self.get_session_context(session_id) # 添加新消息 context.append( { "role": role, "content": content, "timestamp": get_utc_now().isoformat(), } ) # 限制上下文长度,避免数据过大 if len(context) > MAX_CONTEXT_LENGTH: context = context[-MAX_CONTEXT_LENGTH:] # 保存到数据库 self.repository.update_chat_context(session_id, json.dumps(context, ensure_ascii=False)) # ===== 数据库会话管理 ===== def create_chat( self, session_id: str, chat_type: str = "event", title: str | None = None, context_id: int | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: """创建聊天会话(数据库)""" return self.repository.create_chat( session_id=session_id, chat_type=chat_type, title=title, context_id=context_id, metadata=metadata, ) def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None: """根据 session_id 获取聊天会话""" return self.repository.get_chat_by_session_id(session_id) def ensure_chat_exists( self, session_id: str, chat_type: str = "event", title: str | None = None, context_id: int | None = None, ) -> dict[str, Any] | None: """确保聊天会话存在,如果不存在则创建""" chat = self.repository.get_chat_by_session_id(session_id) if not chat: chat = self.repository.create_chat( session_id=session_id, chat_type=chat_type, title=title, context_id=context_id, ) logger.info(f"在数据库中创建会话: {session_id}, 类型: {chat_type}") return chat def list_chats( self, chat_type: str | None = None, limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: """列出聊天会话""" return self.repository.list_chats( chat_type=chat_type, limit=limit, offset=offset, ) def update_chat_title(self, session_id: str, title: str) -> bool: """更新聊天会话标题""" return self.repository.update_chat_title(session_id, title) def delete_chat(self, session_id: str) -> bool: """删除聊天会话及其所有消息""" return self.repository.delete_chat(session_id) # ===== 消息管理 ===== def add_message( self, session_id: str, role: str, content: str, token_count: int | None = None, model: str | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: """添加消息到聊天会话(数据库)""" return self.repository.add_message( session_id=session_id, role=role, content=content, token_count=token_count, model=model, metadata=metadata, ) def get_messages( self, session_id: str, limit: int | None = None, offset: int = 0, ) -> list[dict[str, Any]]: """获取聊天会话的消息列表""" return self.repository.get_messages( session_id=session_id, limit=limit, offset=offset, ) def get_message_count(self, session_id: str) -> int: """获取聊天会话的消息数量""" return self.repository.get_message_count(session_id) def get_chat_summaries( self, chat_type: str | None = None, limit: int = 10, ) -> list[dict[str, Any]]: """获取聊天会话摘要列表""" return self.repository.get_chat_summaries( chat_type=chat_type, limit=limit, ) # ===== 历史记录 ===== def get_chat_history( self, session_id: str | None = None, chat_type: str | None = None, ) -> dict[str, Any]: """获取聊天历史记录""" if session_id: # 返回指定会话的历史记录 messages = self.repository.get_messages(session_id) return { "session_id": session_id, "history": messages, "message": f"会话 {session_id} 的历史记录", } else: # 返回所有会话的摘要信息 sessions_info = self.repository.get_chat_summaries(chat_type=chat_type, limit=20) return {"sessions": sessions_info, "message": "所有会话摘要"} ================================================ FILE: lifetrace/services/config_service.py ================================================ """配置服务层 - 处理配置的保存、比对和重载逻辑""" import os import shutil from collections.abc import Callable from typing import Any import yaml from lifetrace.jobs.scheduler import get_scheduler_manager from lifetrace.llm.llm_client import LLMClient from lifetrace.services.asr_client import ASRClient from lifetrace.util.base_paths import get_config_dir, get_user_config_dir from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import reload_settings, settings logger = get_logger() # LLM 相关配置键(支持两种格式,用于判断是否需要重新初始化 LLM) LLM_RELATED_BACKEND_KEYS = [ # 点分隔格式(后端标准) "llm.api_key", "llm.base_url", "llm.model", # snake_case 格式(前端 fetcher 转换后发送的格式) "llm_api_key", "llm_base_url", "llm_model", ] # ASR 相关配置键(支持两种格式,用于判断是否需要重新初始化 ASR) ASR_RELATED_BACKEND_KEYS = [ # 点分隔格式(后端标准) "audio.asr.api_key", "audio.asr.base_url", "audio.asr.model", # snake_case 格式(前端 fetcher 转换后发送的格式) "audio_asr_api_key", "audio_asr_base_url", "audio_asr_model", ] # 任务启用状态配置键到调度器任务ID的映射(支持两种格式) JOB_ENABLED_CONFIG_TO_JOB_ID = { # 点分隔格式(后端标准) "jobs.recorder.enabled": "recorder_job", "jobs.ocr.enabled": "ocr_job", "jobs.clean_data.enabled": "clean_data_job", "jobs.activity_aggregator.enabled": "activity_aggregator_job", "jobs.todo_recorder.enabled": "todo_recorder_job", "jobs.audio_recording.enabled": "audio_recording_job", # snake_case 格式(前端 fetcher 转换后发送的格式) "jobs_recorder_enabled": "recorder_job", "jobs_ocr_enabled": "ocr_job", "jobs_clean_data_enabled": "clean_data_job", "jobs_activity_aggregator_enabled": "activity_aggregator_job", "jobs_todo_recorder_enabled": "todo_recorder_job", "jobs_audio_recording_enabled": "audio_recording_job", } # 联动配置映射:配置键 -> 需要联动的配置键列表 # 当一个配置变化时,需要同步更新关联的配置 JOB_LINKED_CONFIG = { # auto_todo_detection 与 todo_recorder 联动 "jobs.auto_todo_detection.enabled": ["jobs.todo_recorder.enabled"], "jobs_auto_todo_detection_enabled": ["jobs_todo_recorder_enabled"], "jobs.todo_recorder.enabled": ["jobs.auto_todo_detection.enabled"], "jobs_todo_recorder_enabled": ["jobs_auto_todo_detection_enabled"], } # 简单前缀映射:prefix -> (prefix_length, dot_prefix) _SIMPLE_PREFIX_MAP: dict[str, tuple[int, str]] = { "llm_": (4, "llm"), "server_": (7, "server"), "chat_": (5, "chat"), "dify_": (5, "dify"), "tavily_": (7, "tavily"), } # ASR 配置键名映射(保留下划线的键名) _ASR_KEY_MAPPING: dict[str, str] = { "audio_asr_api_key": "audio.asr.api_key", "audio_asr_base_url": "audio.asr.base_url", "audio_asr_model": "audio.asr.model", "audio_asr_sample_rate": "audio.asr.sample_rate", "audio_asr_format": "audio.asr.format", "audio_asr_semantic_punctuation_enabled": "audio.asr.semantic_punctuation_enabled", "audio_asr_max_sentence_silence": "audio.asr.max_sentence_silence", "audio_asr_heartbeat": "audio.asr.heartbeat", "audio_is_24x7": "audio.is_24x7", } # 复合任务名映射:首部分 -> 完整任务名 _COMPOUND_JOB_NAMES: dict[str, str] = { "clean": "clean_data", "activity": "activity_aggregator", "auto": "auto_todo_detection", "todo": "todo_recorder", } # 最小 jobs 配置部分数量 _MIN_JOBS_PARTS = 3 def _convert_jobs_key(parts: list[str]) -> str: """转换 jobs 相关的配置键""" job_name = parts[1] # recorder, ocr, clean_data, activity_aggregator, etc. # 处理复合任务名 if job_name in _COMPOUND_JOB_NAMES: full_job_name = _COMPOUND_JOB_NAMES[job_name] name_parts = full_job_name.split("_") name_length = len(name_parts) if len(parts) > name_length and parts[1 : name_length + 1] == name_parts: remaining = parts[name_length + 1 :] if remaining: return f"jobs.{full_job_name}.{'.'.join(remaining)}" return f"jobs.{full_job_name}" # 简单任务名 remaining = parts[2:] if not remaining: return f"jobs.{job_name}" # 处理 params 子配置 if remaining[0] == "params" and len(remaining) > 1: return f"jobs.{job_name}.params.{'.'.join(remaining[1:])}" return f"jobs.{job_name}.{'.'.join(remaining)}" def snake_to_dot_notation(key: str) -> str: """将 snake_case 格式的键转换为点分隔格式 前端 fetcher 会将 camelCase 转换为 snake_case 发送给后端, 例如: jobsRecorderEnabled -> jobs_recorder_enabled 后端配置文件使用点分隔格式,例如: jobs.recorder.enabled Args: key: snake_case 格式的键,如 "jobs_recorder_enabled" 或 "llm_api_key" Returns: 点分隔格式的键,如 "jobs.recorder.enabled" 或 "llm.api_key" """ # 如果已经是点分隔格式或不包含下划线,直接返回 if "." in key or "_" not in key: return key # 优先检查 ASR 配置键名映射(需要保留下划线的键) if key in _ASR_KEY_MAPPING: return _ASR_KEY_MAPPING[key] # 处理 jobs 相关配置 if key.startswith("jobs_"): parts = key.split("_") if parts[0] == "jobs" and len(parts) >= _MIN_JOBS_PARTS: return _convert_jobs_key(parts) # 处理简单前缀(llm, server, chat) for prefix, (prefix_len, dot_prefix) in _SIMPLE_PREFIX_MAP.items(): if key.startswith(prefix): return f"{dot_prefix}.{key[prefix_len:]}" # 默认:简单地将下划线替换为点 return key.replace("_", ".") def dot_to_snake_notation(key: str) -> str: """将点分隔格式的键转换为 snake_case 格式 后端配置文件使用点分隔格式,例如: jobs.recorder.enabled 前端 fetcher 需要 snake_case 格式才能转换为 camelCase,例如: jobs_recorder_enabled Args: key: 点分隔格式的键,如 "jobs.recorder.enabled" 或 "llm.api_key" Returns: snake_case 格式的键,如 "jobs_recorder_enabled" 或 "llm_api_key" """ # 如果已经是 snake_case 格式或不包含点,直接返回 if "." not in key: return key # 简单地将点替换为下划线 return key.replace(".", "_") def is_llm_configured() -> bool: """检查 LLM 是否已配置 Returns: bool: 如果 llm_key 和 base_url 都已配置(不是占位符或空),返回 True """ invalid_values = ["", "xxx", "YOUR_API_KEY_HERE", "YOUR_BASE_URL_HERE", "YOUR_LLM_KEY_HERE"] return ( settings.llm.api_key not in invalid_values and settings.llm.base_url not in invalid_values ) class ConfigService: """配置服务类 - 负责配置的保存、比对和热加载""" def __init__(self): """初始化配置服务""" self._config_path = str(get_user_config_dir() / "config.yaml") def compare_config_changes(self, new_settings: dict[str, Any]) -> tuple[bool, list[str]]: """比对配置变更 Args: new_settings: 前端提交的配置字典(键可以是 snake_case 或点分隔格式) Returns: (是否有变更, 变更项列表) """ config_changed = False changed_items = [] for raw_key, new_value in new_settings.items(): # 将 snake_case 格式转换为点分隔格式 backend_key = snake_to_dot_notation(raw_key) try: # 获取当前配置值 old_value = settings.get(backend_key) # 比对新旧值 if old_value != new_value: config_changed = True # 记录变更项(敏感信息脱敏) if "api_key" in backend_key.lower(): changed_items.append( f"{backend_key}: {str(old_value)[:10] if old_value else 'None'}... -> {str(new_value)[:10]}..." ) else: changed_items.append(f"{backend_key}: {old_value} -> {new_value}") except KeyError: # 配置项不存在,视为新增配置 config_changed = True if "api_key" in backend_key.lower(): changed_items.append(f"{backend_key}: (新增) {str(new_value)[:10]}...") else: changed_items.append(f"{backend_key}: (新增) {new_value}") return config_changed, changed_items def get_llm_config(self) -> dict[str, Any]: """获取当前 LLM 配置 Returns: LLM 配置字典 """ return { "api_key": settings.llm.api_key, "base_url": settings.llm.base_url, "model": settings.llm.model, } def get_asr_config(self) -> dict[str, Any]: """获取当前 ASR 配置 Returns: ASR 配置字典 """ try: return { "api_key": settings.audio.asr.api_key, "base_url": settings.audio.asr.base_url, "model": settings.audio.asr.model, } except Exception: return { "api_key": None, "base_url": None, "model": None, } def get_config_for_frontend(self) -> dict[str, Any]: """获取配置(转换为 snake_case 格式供前端使用) 前端 fetcher 会将 snake_case 转换为 camelCase。 后端配置文件使用点分隔格式,需要转换为 snake_case 格式。 Returns: snake_case 格式的配置字典,前端 fetcher 会自动转换为 camelCase """ # 定义需要获取的配置项(后端格式) backend_config_keys = [ # 录制配置 "jobs.recorder.params.auto_exclude_self", "jobs.recorder.params.blacklist.enabled", "jobs.recorder.params.blacklist.apps", "jobs.recorder.enabled", "jobs.recorder.interval", "jobs.recorder.params.screens", "jobs.recorder.params.deduplicate", # LLM配置 "llm.api_key", "llm.base_url", "llm.model", "llm.temperature", "llm.max_tokens", # 服务器配置 "server.host", "server.port", # Clean data 配置 "jobs.clean_data.params.max_days", "jobs.clean_data.params.max_screenshots", # 聊天配置 "chat.enable_history", "chat.history_limit", # 自动待办检测配置 "jobs.auto_todo_detection.enabled", "jobs.auto_todo_detection.params.whitelist.apps", # Todo 专用录制配置 "jobs.todo_recorder.enabled", "jobs.todo_recorder.interval", # Dify 配置 "dify.enabled", "dify.api_key", "dify.base_url", # Tavily 配置(联网搜索) "tavily.api_key", # 音频录制配置 "audio.is_24x7", # 音频录制任务配置 "jobs.audio_recording.enabled", "jobs.audio_recording.interval", # 音频识别(ASR)配置 "audio.asr.api_key", "audio.asr.base_url", "audio.asr.model", "audio.asr.sample_rate", "audio.asr.format", "audio.asr.semantic_punctuation_enabled", "audio.asr.max_sentence_silence", "audio.asr.heartbeat", ] config_dict = {} for backend_key in backend_config_keys: try: value = settings.get(backend_key) # 将点分隔格式转换为 snake_case 格式,以便前端 fetcher 能正确转换为 camelCase frontend_key = dot_to_snake_notation(backend_key) config_dict[frontend_key] = value except KeyError: # 配置项不存在,跳过或使用默认值 logger.debug(f"配置项 {backend_key} 不存在,跳过") continue return config_dict def update_config_file(self, new_settings: dict[str, Any], config_path: str) -> None: """更新配置文件 Args: new_settings: 配置字典(键可以是 snake_case 或点分隔格式) config_path: 配置文件路径 """ # 读取现有配置 with open(config_path, encoding="utf-8") as f: current_config = yaml.safe_load(f) or {} # 更新配置 for raw_key, value in new_settings.items(): # 将 snake_case 格式转换为点分隔格式 backend_key = snake_to_dot_notation(raw_key) logger.info(f"更新配置: {raw_key} -> {backend_key} = {value}") # 处理嵌套配置键 keys = backend_key.split(".") current = current_config for key in keys[:-1]: if key not in current: current[key] = {} current = current[key] current[keys[-1]] = value # 保存配置文件 with open(config_path, "w", encoding="utf-8") as f: yaml.dump(current_config, f, allow_unicode=True, sort_keys=False) logger.info(f"配置已保存到: {config_path}") def _collect_jobs_to_sync( self, job_config_keys: list[str], new_settings: dict[str, Any] ) -> dict[str, bool]: """收集需要同步的任务(包括联动任务)""" jobs_to_sync: dict[str, bool] = {} for config_key in job_config_keys: job_id = JOB_ENABLED_CONFIG_TO_JOB_ID[config_key] enabled = new_settings[config_key] jobs_to_sync[job_id] = enabled # 检查是否有联动配置 if config_key in JOB_LINKED_CONFIG: self._add_linked_jobs(config_key, job_id, enabled, jobs_to_sync) return jobs_to_sync def _add_linked_jobs( self, config_key: str, job_id: str, enabled: bool, jobs_to_sync: dict[str, bool] ) -> None: """添加联动任务到同步列表""" linked_keys = JOB_LINKED_CONFIG[config_key] for linked_key in linked_keys: if linked_key in JOB_ENABLED_CONFIG_TO_JOB_ID: linked_job_id = JOB_ENABLED_CONFIG_TO_JOB_ID[linked_key] if linked_job_id not in jobs_to_sync: jobs_to_sync[linked_job_id] = enabled logger.info(f"📢 联动同步:{job_id} -> {linked_job_id} = {enabled}") def sync_job_states_if_needed(self, new_settings: dict[str, Any]) -> None: """如果任务启用状态发生变化,同步到调度器 Args: new_settings: 配置字典(键可以是 snake_case 或点分隔格式) """ job_config_keys = [key for key in new_settings if key in JOB_ENABLED_CONFIG_TO_JOB_ID] if not job_config_keys: return try: scheduler_manager = get_scheduler_manager() jobs_to_sync = self._collect_jobs_to_sync(job_config_keys, new_settings) for job_id, enabled in jobs_to_sync.items(): job = scheduler_manager.get_job(job_id) if not job: logger.warning(f"任务 {job_id} 不存在,跳过状态同步") continue is_running = job.next_run_time is not None if enabled and not is_running: scheduler_manager.resume_job(job_id) logger.info(f"📢 配置变更:任务 {job_id} 已恢复运行") elif not enabled and is_running: scheduler_manager.pause_job(job_id) logger.info(f"📢 配置变更:任务 {job_id} 已暂停") except Exception as e: logger.error(f"同步任务状态失败: {e}", exc_info=True) def reinitialize_llm_if_needed( self, new_settings: dict[str, Any], old_llm_config: dict[str, Any], is_llm_configured_callback: Callable[[], None] | None = None, ) -> None: """如果 LLM 配置发生变化,重新初始化 LLM 客户端 Args: new_settings: 配置字典(键为后端格式) old_llm_config: 旧的 LLM 配置 is_llm_configured_callback: 更新 LLM 配置状态的回调函数 """ # 检测是否有 LLM 相关配置项在请求中 has_llm_keys = any(key in LLM_RELATED_BACKEND_KEYS for key in new_settings) if not has_llm_keys: return # 获取新的 LLM 配置值 new_llm_config = self.get_llm_config() # 比对新旧配置值 llm_config_changed = old_llm_config != new_llm_config if llm_config_changed: logger.info("检测到 LLM 配置实际发生变更,正在热加载 LLM 客户端...") logger.info( f"旧配置: API Key={old_llm_config['api_key'][:10] if old_llm_config['api_key'] else 'None'}..., " f"Base URL={old_llm_config['base_url']}, Model={old_llm_config['model']}" ) logger.info( f"新配置: API Key={new_llm_config['api_key'][:10] if new_llm_config['api_key'] else 'None'}..., " f"Base URL={new_llm_config['base_url']}, Model={new_llm_config['model']}" ) try: # 更新配置状态 if is_llm_configured_callback: is_llm_configured_callback() configured = is_llm_configured() status = "已配置" if configured else "未配置" logger.info(f"LLM 配置状态已更新: {status}") # 重新初始化 LLM 客户端单例(所有服务共享此实例) llm_client = LLMClient() client_available = llm_client.reinitialize() logger.info(f"LLM 客户端已重新初始化 - 可用: {client_available}") if client_available: logger.info( f"LLM 客户端热加载成功 - " f"API Key: {llm_client.api_key[:10]}..., " f"Model: {llm_client.model}" ) logger.info("所有服务将自动使用更新后的 LLM 客户端") else: logger.warning("LLM 客户端重新初始化后不可用,请检查配置") logger.info("LLM 配置热加载完成") except Exception as e: logger.error(f"热加载 LLM 客户端失败: {e}", exc_info=True) else: logger.info("LLM 配置未发生实际变更,跳过重新加载") def reinitialize_asr_if_needed( self, new_settings: dict[str, Any], old_asr_config: dict[str, Any], ) -> None: """如果 ASR 配置发生变化,重新初始化 ASR 客户端 Args: new_settings: 配置字典(键为后端格式) old_asr_config: 旧的 ASR 配置 """ # 检测是否有 ASR 相关配置项在请求中 has_asr_keys = any(key in ASR_RELATED_BACKEND_KEYS for key in new_settings) if not has_asr_keys: return # 获取新的 ASR 配置值 new_asr_config = self.get_asr_config() # 比对新旧配置值 asr_config_changed = old_asr_config != new_asr_config if asr_config_changed: logger.info("检测到 ASR 配置实际发生变更,正在热加载 ASR 客户端...") logger.info( f"旧配置: API Key={old_asr_config['api_key'][:10] if old_asr_config['api_key'] else 'None'}..., " f"Base URL={old_asr_config['base_url']}, Model={old_asr_config['model']}" ) logger.info( f"新配置: API Key={new_asr_config['api_key'][:10] if new_asr_config['api_key'] else 'None'}..., " f"Base URL={new_asr_config['base_url']}, Model={new_asr_config['model']}" ) try: # 重新初始化 ASR 客户端单例 asr_client = ASRClient() asr_client.reinitialize() logger.info( f"ASR 客户端热加载成功 - " f"API Key: {asr_client.api_key[:10] if asr_client.api_key else 'None'}..., " f"Model: {asr_client.model}" ) logger.info("ASR 配置热加载完成") except Exception as e: logger.error(f"热加载 ASR 客户端失败: {e}", exc_info=True) else: logger.info("ASR 配置未发生实际变更,跳过重新加载") def save_config( self, new_settings: dict[str, Any], is_llm_configured_callback: Callable[[], None] | None = None, ) -> dict[str, Any]: """保存配置(主入口方法) Args: new_settings: 配置字典(键为后端格式) is_llm_configured_callback: 更新 LLM 配置状态的回调函数 Returns: 操作结果字典 """ config_path = self._config_path # 如果配置文件不存在,从默认配置复制 if not os.path.exists(config_path): self._init_config_file() # 1. 先比对配置是否真的发生了变化 config_changed, changed_items = self.compare_config_changes(new_settings) # 如果配置没有发生变化,直接返回 if not config_changed: logger.info("配置未发生变化,跳过保存和重载") return {"success": True, "message": "配置未发生变化"} # 记录变更信息 logger.info(f"检测到配置变更,共 {len(changed_items)} 项:") for item in changed_items: logger.info(f" - {item}") # 2. 保存旧的 LLM 和 ASR 配置值(用于后续比对是否需要重新初始化) old_llm_config = self.get_llm_config() old_asr_config = self.get_asr_config() # 3. 更新配置文件 self.update_config_file(new_settings, config_path) # 4. 重新加载配置(使用封装函数,正确处理返回值) reload_success = reload_settings() if reload_success: logger.info("配置已重新加载到内存") else: logger.warning("配置重新加载失败,但文件已保存") # 5. 同步任务状态到调度器(在配置重载后执行,确保使用最新的配置值) self.sync_job_states_if_needed(new_settings) # 6. 如果需要,重新初始化 LLM 客户端 self.reinitialize_llm_if_needed(new_settings, old_llm_config, is_llm_configured_callback) # 7. 如果需要,重新初始化 ASR 客户端 self.reinitialize_asr_if_needed(new_settings, old_asr_config) return {"success": True, "message": "配置保存成功"} def _init_config_file(self) -> None: """从默认配置初始化配置文件""" default_config_path = get_config_dir() / "default_config.yaml" if not default_config_path.exists(): raise FileNotFoundError( f"默认配置文件不存在: {default_config_path}\n" "请确保 default_config.yaml 文件存在于 config 目录中" ) os.makedirs(os.path.dirname(self._config_path), exist_ok=True) shutil.copy2(default_config_path, self._config_path) reload_settings() ================================================ FILE: lifetrace/services/dify_client.py ================================================ """Dify 集成客户端(测试模式用) 目前仅用于在 Chat 流式接口中提供一个简单的测试通道: - 接收一段用户消息 - 调用 Dify 的 chat-messages 接口(支持流式和非流式) - 返回流式增量文本或完整回复 如果后续需要更复杂的能力(带历史、多轮、变量等),可以在此文件中继续扩展。 """ from __future__ import annotations import json from typing import TYPE_CHECKING, Any import httpx from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings logger = get_logger() if TYPE_CHECKING: from collections.abc import Iterator def _get_dify_config() -> dict[str, str]: """从 dynaconf 配置中读取 Dify 相关设置。 支持以下 dynaconf 路径(config.yaml 中): - dify.enabled: 是否启用 Dify(可选,默认为 True) - dify.api_key: Dify API Key(必填) - dify.base_url: Dify API Base URL,默认 https://api.dify.ai/v1 """ enabled = getattr(getattr(settings, "dify", {}), "enabled", True) if enabled is False: raise RuntimeError("Dify 功能已在配置中关闭(dify.enabled = false)") api_key = getattr(getattr(settings, "dify", {}), "api_key", "").strip() if not api_key: raise RuntimeError("未配置 Dify API Key(dify.api_key),请在设置面板中填写") base_url = getattr(getattr(settings, "dify", {}), "base_url", "https://api.dify.ai/v1") base_url = str(base_url).rstrip("/") return { "api_key": api_key, "base_url": base_url, } def _parse_sse_event_block(event_block: str) -> dict[str, Any] | None: """解析单个 SSE 事件块,提取 JSON 数据。 Args: event_block: SSE 事件块文本(不包含 \n\n 分隔符) Returns: 解析后的 JSON 数据字典,如果解析失败则返回 None """ for raw_line in event_block.split("\n"): line = raw_line.strip() if line.startswith("data: "): data_str = line[6:] # 跳过 "data: " 前缀 try: return json.loads(data_str) except json.JSONDecodeError: logger.warning(f"[dify] 解析 SSE 数据 JSON 失败,原始数据: {data_str}") continue return None def _extract_answer_from_sse_data(data_content: dict[str, Any]) -> str | None: """从 SSE 数据内容中提取 answer 字段。 Args: data_content: 解析后的 SSE 事件数据字典 Returns: answer 文本内容,如果不存在则返回 None """ answer_delta = data_content.get("answer", "") return answer_delta if answer_delta else None def _process_sse_event_block(event_block: str) -> Iterator[str]: """处理单个 SSE 事件块,提取并 yield answer 内容。 Args: event_block: SSE 事件块文本(已去除 \n\n 分隔符并 strip) Yields: 增量文本内容(如果有) """ if not event_block: return data_content = _parse_sse_event_block(event_block) if not data_content: return answer_delta = _extract_answer_from_sse_data(data_content) if answer_delta: yield answer_delta event_type = data_content.get("event", "") if event_type == "message_end": logger.info("[dify] 流式响应接收完成") def _parse_sse_stream(response: httpx.Response) -> Iterator[str]: """解析 SSE 格式的流式响应,提取 answer 字段。 Args: response: httpx 流式响应对象 Yields: 增量文本内容 """ buffer = "" for chunk in response.iter_text(): if not chunk: continue buffer += chunk # SSE 格式:每个事件以 \n\n 分隔 while "\n\n" in buffer: event_block, buffer = buffer.split("\n\n", 1) yield from _process_sse_event_block(event_block.strip()) # 处理剩余的 buffer(最后一个可能不完整的 SSE 事件) if buffer.strip(): yield from _process_sse_event_block(buffer.strip()) def _handle_blocking_response(response: httpx.Response) -> Iterator[str]: """处理 blocking 模式的响应,返回完整回复。 Args: response: httpx 响应对象 Yields: 完整的回复文本(作为单个元素) """ try: data = response.json() except json.JSONDecodeError as e: logger.error(f"[dify] 解析响应 JSON 失败: {e}") raise # Dify 一般会返回 answer 字段,这里做一些兜底 answer = data.get("answer") or data.get("output") or data.get("result") or "" if not answer: logger.warning("[dify] 响应中未找到 answer 字段,将返回原始 JSON 文本") answer = str(data) yield answer def call_dify_chat( message: str, user: str | None = None, response_mode: str = "streaming", inputs: dict[str, Any] | None = None, **extra_payload: Any, ) -> Iterator[str]: """调用 Dify chat-messages 接口,返回文本生成器。 Args: message: 用户消息内容 user: 用户标识,默认为 "lifetrace-user" response_mode: 响应模式,可选 "streaming"(默认)或 "blocking" inputs: Dify 输入变量字典,默认为空字典 **extra_payload: 额外的 payload 参数,会被合并到请求 payload 中 Yields: 文本内容(流式模式下为增量文本,阻塞模式下为完整文本) """ cfg = _get_dify_config() headers = { "Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json", } # 构建 payload,允许前端通过参数自定义所有字段 payload: dict[str, Any] = { "inputs": inputs if inputs is not None else {}, "query": message, "response_mode": response_mode, "user": user or "lifetrace-user", **extra_payload, # 允许前端传入额外的参数 } url = f"{cfg['base_url']}/chat-messages" logger.info(f"[dify] 调用 Dify chat-messages 接口({response_mode} 模式)") try: with httpx.Client(timeout=60) as client: if response_mode == "streaming": # 流式模式:使用 stream 方法 with client.stream("POST", url, headers=headers, json=payload) as response: response.raise_for_status() yield from _parse_sse_stream(response) else: # 阻塞模式:使用普通 post 方法 response = client.post(url, headers=headers, json=payload) response.raise_for_status() yield from _handle_blocking_response(response) except Exception as e: logger.error(f"[dify] 调用失败 ({response_mode} 模式): {e}") raise ================================================ FILE: lifetrace/services/event_service.py ================================================ """Event 业务逻辑层 处理 Event 相关的业务逻辑,与数据访问层解耦。 """ import importlib from datetime import datetime from typing import Any from fastapi import HTTPException from lifetrace.repositories.interfaces import IEventRepository, IOcrRepository from lifetrace.schemas.event import EventDetailResponse, EventListResponse, EventResponse from lifetrace.schemas.screenshot import ScreenshotResponse from lifetrace.util.logging_config import get_logger logger = get_logger() class EventService: """Event 业务逻辑层""" def __init__(self, event_repository: IEventRepository, ocr_repository: IOcrRepository): self.event_repo = event_repository self.ocr_repo = ocr_repository def list_events( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, app_name: str | None, ) -> EventListResponse: """获取事件列表""" logger.info( f"获取事件列表 - 参数: limit={limit}, offset={offset}, " f"start_date={start_date}, end_date={end_date}, app_name={app_name}" ) events = self.event_repo.list_events( limit=limit, offset=offset, start_date=start_date, end_date=end_date, app_name=app_name, ) total_count = self.event_repo.count_events( start_date=start_date, end_date=end_date, app_name=app_name, ) logger.info(f"获取事件列表 - 结果: events_count={len(events)}, total_count={total_count}") return EventListResponse( events=[EventResponse(**e) for e in events], total_count=total_count, ) def count_events( self, start_date: datetime | None, end_date: datetime | None, app_name: str | None, ) -> dict[str, int]: """获取事件总数""" count = self.event_repo.count_events( start_date=start_date, end_date=end_date, app_name=app_name, ) return {"count": count} def get_event_detail(self, event_id: int) -> EventDetailResponse: """获取事件详情""" event_summary = self.event_repo.get_summary(event_id) if not event_summary: raise HTTPException(status_code=404, detail="事件不存在") screenshots = self.event_repo.get_screenshots(event_id) screenshots_resp = [ ScreenshotResponse( id=s["id"], file_path=s["file_path"], app_name=s["app_name"], window_title=s["window_title"], created_at=s["created_at"], text_content=None, width=s["width"], height=s["height"], ) for s in screenshots ] return EventDetailResponse( id=event_summary["id"], app_name=event_summary["app_name"], window_title=event_summary["window_title"], start_time=event_summary["start_time"], end_time=event_summary["end_time"], screenshots=screenshots_resp, ai_title=event_summary.get("ai_title"), ai_summary=event_summary.get("ai_summary"), ) def get_event_context(self, event_id: int) -> dict[str, Any]: """获取事件的OCR文本上下文""" event_summary = self.event_repo.get_summary(event_id) if not event_summary: raise HTTPException(status_code=404, detail="事件不存在") screenshots = self.event_repo.get_screenshots(event_id) # 聚合OCR文本 ocr_texts = [] for screenshot in screenshots: ocr_results = self.ocr_repo.get_results_by_screenshot(screenshot["id"]) if ocr_results: for ocr in ocr_results: if ocr.get("text_content"): ocr_texts.append(ocr["text_content"]) break return { "event_id": event_id, "app_name": event_summary.get("app_name"), "window_title": event_summary.get("window_title"), "start_time": event_summary.get("start_time"), "end_time": event_summary.get("end_time"), "ocr_texts": ocr_texts, "screenshot_count": len(screenshots), } def generate_event_summary(self, event_id: int) -> dict[str, Any]: """手动触发单个事件的摘要生成""" # 检查事件是否存在 event_info = self.event_repo.get_summary(event_id) if not event_info: raise HTTPException(status_code=404, detail="事件不存在") # 延迟导入避免循环依赖 summary_module = importlib.import_module("lifetrace.llm.event_summary_service") success = summary_module.event_summary_service.generate_event_summary(event_id) if success: updated_event = self.event_repo.get_summary(event_id) if not updated_event: raise HTTPException(status_code=500, detail="事件摘要更新后未找到事件数据") return { "success": True, "event_id": event_id, "ai_title": updated_event.get("ai_title"), "ai_summary": updated_event.get("ai_summary"), } else: raise HTTPException(status_code=500, detail="摘要生成失败") def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]: """批量获取事件""" return self.event_repo.get_events_by_ids(event_ids) ================================================ FILE: lifetrace/services/icalendar_service.py ================================================ """iCalendar (ICS) import/export service for Todo items.""" from __future__ import annotations from datetime import date, datetime, time from typing import Any from icalendar import Calendar, vRecur from icalendar import Event as VEvent from icalendar import Todo as VTodo from lifetrace.schemas.todo import TodoCreate, TodoItemType, TodoPriority, TodoStatus from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import ensure_utc, naive_as_utc, to_local logger = get_logger() PERCENT_COMPLETE_MAX = 100 ICAL_PRIORITY_HIGH = 1 ICAL_PRIORITY_MEDIUM = 5 ICAL_PRIORITY_LOW = 9 ICAL_PRIORITY_NONE = 0 def _normalize_percent(value: Any) -> int: if value is None: return 0 try: percent = int(value) except Exception: return 0 return max(0, min(PERCENT_COMPLETE_MAX, percent)) def _to_local_time(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: value = naive_as_utc(value) return to_local(value) def _from_ical_dt(value: Any) -> datetime | None: if value is None: return None if hasattr(value, "dt"): value = value.dt if isinstance(value, datetime): return ensure_utc(value) if isinstance(value, date): return ensure_utc(datetime.combine(value, time.min)) return None def _build_calendar() -> Calendar: cal = Calendar() cal.add("prodid", "-//LifeTrace//FreeTodo//EN") cal.add("version", "2.0") cal.add("calscale", "GREGORIAN") return cal def _add_optional_text(component: VTodo | VEvent, name: str, value: Any) -> None: text = (value or "").strip() if text: component.add(name, text) def _add_optional_dt(component: VTodo | VEvent, name: str, value: Any) -> None: dt_value = _to_local_time(value) if dt_value: component.add(name, dt_value) def _add_optional_value(component: VTodo | VEvent, name: str, value: Any) -> None: if value is not None: component.add(name, value) def _add_optional_categories(component: VTodo | VEvent, tags: list[Any]) -> None: if tags: component.add("categories", [str(t) for t in tags if t]) def _add_optional_rrule(component: VTodo | VEvent, rrule: str) -> None: if not rrule: return try: component.add("rrule", vRecur.from_ical(rrule)) except Exception: component.add("rrule", rrule) class ICalendarService: """Convert Todo objects to/from iCalendar VTODO components.""" def export_todos(self, todos: list[dict[str, Any]]) -> str: cal = _build_calendar() for todo in todos: try: item_type = (todo.get("item_type") or "VTODO").upper() if item_type == "VEVENT": cal.add_component(self._todo_to_vevent(todo)) else: cal.add_component(self._todo_to_vtodo(todo)) except Exception as exc: logger.warning(f"跳过 todo 导出(ICS): {exc}") return cal.to_ical().decode("utf-8") def import_todos(self, ics_content: str) -> list[TodoCreate]: # noqa: C901 cal = Calendar.from_ical(ics_content) todos: list[TodoCreate] = [] for component in cal.walk(): if component.name not in ("VTODO", "VEVENT"): continue summary = str(component.get("summary") or "").strip() if not summary: continue item_type = TodoItemType.VEVENT if component.name == "VEVENT" else TodoItemType.VTODO uid = str(component.get("uid")) if component.get("uid") else None description = ( str(component.get("description")).strip() if component.get("description") else None ) dtstart = _from_ical_dt(component.get("dtstart")) dtend = _from_ical_dt(component.get("dtend")) if item_type == "VEVENT" else None due = _from_ical_dt(component.get("due")) if item_type == "VTODO" else None duration_prop = component.get("duration") duration = None if duration_prop is not None: try: duration = duration_prop.to_ical().decode("utf-8") except Exception: duration = str(duration_prop) completed_at = _from_ical_dt(component.get("completed")) start_time = dtstart or due end_time = dtend percent_complete = _normalize_percent(component.get("percent-complete")) status = self._status_from_ical(component.get("status")) if status is None: status = ( TodoStatus.COMPLETED if percent_complete == PERCENT_COMPLETE_MAX else TodoStatus.ACTIVE ) priority = self._priority_from_ical(component.get("priority")) categories = component.get("categories") tags: list[str] = [] if categories: if hasattr(categories, "cats"): tags = [str(c) for c in categories.cats if c] elif isinstance(categories, list | tuple | set): tags = [str(c) for c in categories if c] else: tags = [str(categories)] rrule_prop = component.get("rrule") rrule = None if rrule_prop: try: rrule = rrule_prop.to_ical().decode("utf-8") except Exception: rrule = str(rrule_prop) todos.append( TodoCreate( uid=uid, name=summary, summary=summary, description=description, user_notes=None, parent_todo_id=None, item_type=item_type, location=None, categories=",".join(tags) if tags else None, classification=None, start_time=start_time, deadline=None, end_time=end_time, dtstart=dtstart, dtend=dtend, due=due, duration=duration, time_zone=None, tzid=None, is_all_day=None, dtstamp=None, created=None, last_modified=None, sequence=None, rdate=None, exdate=None, recurrence_id=None, related_to_uid=None, related_to_reltype=None, ical_status=None, reminder_offsets=None, status=status, priority=priority, completed_at=completed_at, percent_complete=percent_complete, rrule=rrule, order=0, tags=tags, ) ) return todos def _status_to_ical(self, status: str | None) -> str | None: if not status: return None mapping = { "active": "NEEDS-ACTION", "completed": "COMPLETED", "canceled": "CANCELLED", "draft": "NEEDS-ACTION", } return mapping.get(status, "NEEDS-ACTION") def _status_from_ical(self, status: Any) -> TodoStatus | None: if not status: return None status_str = str(status).upper() if status_str in ("COMPLETED",): return TodoStatus.COMPLETED if status_str in ("CANCELLED", "CANCELED"): return TodoStatus.CANCELED if status_str in ("IN-PROCESS", "NEEDS-ACTION", "ACTION"): return TodoStatus.ACTIVE return None def _priority_to_ical(self, priority: str | None) -> int | None: if not priority: return ICAL_PRIORITY_NONE mapping = { "high": ICAL_PRIORITY_HIGH, "medium": ICAL_PRIORITY_MEDIUM, "low": ICAL_PRIORITY_LOW, "none": ICAL_PRIORITY_NONE, } return mapping.get(priority, ICAL_PRIORITY_NONE) def _priority_from_ical(self, priority: Any) -> TodoPriority: if priority is None: return TodoPriority.NONE try: value = int(priority) except Exception: return TodoPriority.NONE if value <= ICAL_PRIORITY_HIGH: return TodoPriority.HIGH if value <= ICAL_PRIORITY_MEDIUM: return TodoPriority.MEDIUM if value <= ICAL_PRIORITY_LOW: return TodoPriority.LOW return TodoPriority.NONE def _todo_to_vtodo(self, todo: dict[str, Any]) -> VTodo: vtodo = VTodo() uid = todo.get("uid") or str(todo.get("id") or "") if uid: vtodo.add("uid", uid) summary = todo.get("summary") or todo.get("name") _add_optional_text(vtodo, "summary", summary) _add_optional_text(vtodo, "description", todo.get("description")) dtstart = todo.get("dtstart") or todo.get("start_time") or todo.get("deadline") due = todo.get("due") or todo.get("deadline") duration = todo.get("duration") _add_optional_dt(vtodo, "dtstart", dtstart) if duration: _add_optional_value(vtodo, "duration", duration) else: _add_optional_dt(vtodo, "due", due or dtstart) _add_optional_dt(vtodo, "created", todo.get("created") or todo.get("created_at")) _add_optional_dt( vtodo, "last-modified", todo.get("last_modified") or todo.get("updated_at"), ) _add_optional_value( vtodo, "status", todo.get("ical_status") or self._status_to_ical(todo.get("status")) ) _add_optional_value(vtodo, "priority", self._priority_to_ical(todo.get("priority"))) _add_optional_dt(vtodo, "completed", todo.get("completed_at")) percent_complete = _normalize_percent(todo.get("percent_complete")) if percent_complete: vtodo.add("percent-complete", percent_complete) categories_text = (todo.get("categories") or "").strip() categories = [] if categories_text: categories = [c.strip() for c in categories_text.split(",") if c.strip()] categories.extend(todo.get("tags") or []) _add_optional_categories(vtodo, categories) _add_optional_rrule(vtodo, (todo.get("rrule") or "").strip()) return vtodo def _todo_to_vevent(self, todo: dict[str, Any]) -> VEvent: vevent = VEvent() uid = todo.get("uid") or str(todo.get("id") or "") if uid: vevent.add("uid", uid) summary = todo.get("summary") or todo.get("name") _add_optional_text(vevent, "summary", summary) _add_optional_text(vevent, "description", todo.get("description")) dtstart = todo.get("dtstart") or todo.get("start_time") dtend = todo.get("dtend") or todo.get("end_time") duration = todo.get("duration") _add_optional_dt(vevent, "dtstart", dtstart) if duration: _add_optional_value(vevent, "duration", duration) else: _add_optional_dt(vevent, "dtend", dtend) _add_optional_dt(vevent, "created", todo.get("created") or todo.get("created_at")) _add_optional_dt( vevent, "last-modified", todo.get("last_modified") or todo.get("updated_at"), ) _add_optional_value( vevent, "status", todo.get("ical_status") or self._status_to_ical(todo.get("status")) ) _add_optional_value(vevent, "priority", self._priority_to_ical(todo.get("priority"))) categories_text = (todo.get("categories") or "").strip() categories = [] if categories_text: categories = [c.strip() for c in categories_text.split(",") if c.strip()] categories.extend(todo.get("tags") or []) _add_optional_categories(vevent, categories) _add_optional_rrule(vevent, (todo.get("rrule") or "").strip()) return vevent ================================================ FILE: lifetrace/services/journal_service.py ================================================ """Journal 业务逻辑层 处理 Journal 相关的业务逻辑,与数据访问层解耦。 """ from __future__ import annotations import re from datetime import datetime, time, timedelta from typing import TYPE_CHECKING, Any from fastapi import HTTPException from sqlalchemy import or_ from lifetrace.llm.journal_generation_service import journal_generation_service from lifetrace.schemas.journal import ( JournalAutoLinkCandidate, JournalAutoLinkRequest, JournalAutoLinkResponse, JournalCreate, JournalGenerateRequest, JournalGenerateResponse, JournalListResponse, JournalResponse, JournalUpdate, ) from lifetrace.storage.journal_manager import JournalCreatePayload, JournalUpdatePayload from lifetrace.storage.models import Activity, Todo from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger logger = get_logger() if TYPE_CHECKING: from collections.abc import Callable from lifetrace.repositories.interfaces import IJournalRepository from lifetrace.storage.database_base import DatabaseBase _DEFAULT_BUCKET_START = time(hour=4, minute=0) class JournalService: """Journal 业务逻辑层""" def __init__(self, repository: IJournalRepository, db_base: DatabaseBase): self.repository = repository self.db_base = db_base def _normalize_name(self, name: str | None) -> str: cleaned = (name or "").strip() return cleaned or "Untitled" def _resolve_day_bucket_range( self, date: datetime, day_bucket_start: datetime | None ) -> tuple[datetime, datetime]: bucket_time = (day_bucket_start or date).time() if day_bucket_start is None: bucket_time = _DEFAULT_BUCKET_START bucket_start = datetime.combine(date.date(), bucket_time, tzinfo=date.tzinfo) if date < bucket_start: bucket_start -= timedelta(days=1) bucket_end = bucket_start + timedelta(days=1) return bucket_start, bucket_end def _extract_keywords(self, text: str) -> list[str]: if not text: return [] normalized = text.lower() english = re.findall(r"[a-z0-9][a-z0-9_-]{1,}", normalized) chinese = re.findall(r"[\u4e00-\u9fff]{2,}", text) return sorted(set(english + chinese)) def _score_text(self, text: str, keywords: list[str]) -> float: if not text or not keywords: return 0.0 lowered = text.lower() score = sum(1 for keyword in keywords if keyword in lowered) return float(score) def _score_candidates( self, items: list[dict[str, Any]], keywords: list[str], text_builder: Callable[[dict[str, Any]], str], ) -> list[dict[str, Any]]: candidates: list[dict[str, Any]] = [] for item in items: text = text_builder(item) score = self._score_text(text, keywords) if score <= 0: continue candidates.append( { "id": item["id"], "name": item.get("name") or item.get("title") or "", "score": score, } ) candidates.sort(key=lambda item: (-item["score"], item["id"])) return candidates def _list_todos_for_range(self, start: datetime, end: datetime) -> list[dict[str, Any]]: with self.db_base.get_session() as session: query = session.query(Todo).filter(col(Todo.deleted_at).is_(None)) query = query.filter( or_( col(Todo.start_time).between(start, end), col(Todo.end_time).between(start, end), col(Todo.deadline).between(start, end), col(Todo.created_at).between(start, end), ) ) todos = query.order_by(col(Todo.created_at).desc()).all() return [ { "id": todo.id, "name": todo.name, "description": todo.description, "user_notes": todo.user_notes, "status": todo.status, "deadline": todo.deadline, "start_time": todo.start_time, "end_time": todo.end_time, } for todo in todos ] def _list_activities_for_range(self, start: datetime, end: datetime) -> list[dict[str, Any]]: with self.db_base.get_session() as session: query = ( session.query(Activity) .filter(col(Activity.deleted_at).is_(None)) .filter(col(Activity.start_time) >= start) .filter(col(Activity.start_time) <= end) ) activities = query.order_by(col(Activity.start_time).desc()).all() return [ { "id": activity.id, "title": activity.ai_title or "", "summary": activity.ai_summary or "", "start_time": activity.start_time, "end_time": activity.end_time, } for activity in activities ] def _resolve_generation_context( self, payload: JournalGenerateRequest ) -> tuple[dict[str, Any] | None, datetime, str, str, datetime | None]: journal = None if payload.journal_id is not None: journal = self.repository.get_by_id(payload.journal_id) if not journal: raise HTTPException(status_code=404, detail="日记不存在") date = payload.date or (journal.get("date") if journal else None) if date is None: raise HTTPException(status_code=400, detail="缺少日记日期") title = payload.title or (journal.get("name") if journal else "") or "" content_original = ( payload.content_original if payload.content_original is not None else (journal.get("user_notes") if journal else "") ) content_original = content_original or "" day_bucket_start = payload.day_bucket_start or ( journal.get("day_bucket_start") if journal else None ) return journal, date, title, content_original, day_bucket_start def get_journal(self, journal_id: int) -> JournalResponse: """获取单个日记""" journal = self.repository.get_by_id(journal_id) if not journal: raise HTTPException(status_code=404, detail="日记不存在") return JournalResponse(**journal) def list_journals( self, limit: int, offset: int, start_date: datetime | None, end_date: datetime | None, ) -> JournalListResponse: """获取日记列表""" journals = self.repository.list_journals(limit, offset, start_date, end_date) total = self.repository.count(start_date, end_date) return JournalListResponse( total=total, journals=[JournalResponse(**j) for j in journals], ) def create_journal(self, data: JournalCreate) -> JournalResponse: """创建日记""" payload = JournalCreatePayload( uid=data.uid, name=self._normalize_name(data.name), user_notes=data.user_notes, date=data.date, content_format=data.content_format or "markdown", content_objective=data.content_objective, content_ai=data.content_ai, mood=data.mood, energy=data.energy, day_bucket_start=data.day_bucket_start, tags=data.tags, related_todo_ids=data.related_todo_ids, related_activity_ids=data.related_activity_ids, ) journal_id = self.repository.create(payload) if not journal_id: raise HTTPException(status_code=500, detail="创建日记失败") logger.info(f"成功创建日记: {journal_id} - {payload.name}") return self.get_journal(journal_id) def _build_update_payload(self, data: JournalUpdate) -> JournalUpdatePayload: update_data = data.model_dump(exclude_none=True) if "name" in update_data: update_data["name"] = self._normalize_name(update_data["name"]) return JournalUpdatePayload(**update_data) def update_journal(self, journal_id: int, data: JournalUpdate) -> JournalResponse: """更新日记""" if not self.repository.get_by_id(journal_id): raise HTTPException(status_code=404, detail="日记不存在") payload = self._build_update_payload(data) if not self.repository.update(journal_id, payload): raise HTTPException(status_code=500, detail="更新日记失败") logger.info(f"成功更新日记: {journal_id}") return self.get_journal(journal_id) def delete_journal(self, journal_id: int) -> None: """删除日记""" if not self.repository.get_by_id(journal_id): raise HTTPException(status_code=404, detail="日记不存在") if not self.repository.delete(journal_id): raise HTTPException(status_code=500, detail="删除日记失败") logger.info(f"成功删除日记: {journal_id}") def auto_link(self, payload: JournalAutoLinkRequest) -> JournalAutoLinkResponse: journal = None if payload.journal_id is not None: journal = self.repository.get_by_id(payload.journal_id) if not journal: raise HTTPException(status_code=404, detail="日记不存在") title = payload.title or (journal.get("name") if journal else "") or "" content_original = ( payload.content_original if payload.content_original is not None else (journal.get("user_notes") if journal else "") ) day_bucket_start = payload.day_bucket_start or ( journal.get("day_bucket_start") if journal else None ) start_time, end_time = self._resolve_day_bucket_range(payload.date, day_bucket_start) todos = self._list_todos_for_range(start_time, end_time) activities = self._list_activities_for_range(start_time, end_time) keywords = self._extract_keywords(f"{title} {content_original}") todo_candidates = self._score_candidates( todos, keywords, lambda item: " ".join( filter(None, [item.get("name"), item.get("description"), item.get("user_notes")]) ), ) activity_candidates = self._score_candidates( activities, keywords, lambda item: " ".join(filter(None, [item.get("title"), item.get("summary")])), ) related_todo_ids = [c["id"] for c in todo_candidates[: payload.max_items]] related_activity_ids = [c["id"] for c in activity_candidates[: payload.max_items]] if payload.journal_id is not None: update_payload = JournalUpdatePayload( related_todo_ids=related_todo_ids, related_activity_ids=related_activity_ids, ) self.repository.update(payload.journal_id, update_payload) return JournalAutoLinkResponse( related_todo_ids=related_todo_ids, related_activity_ids=related_activity_ids, todo_candidates=[JournalAutoLinkCandidate(**c) for c in todo_candidates], activity_candidates=[JournalAutoLinkCandidate(**c) for c in activity_candidates], ) def generate_objective(self, payload: JournalGenerateRequest) -> JournalGenerateResponse: journal, date, _title, content_original, day_bucket_start = ( self._resolve_generation_context(payload) ) start_time, end_time = self._resolve_day_bucket_range(date, day_bucket_start) todos = self._list_todos_for_range(start_time, end_time) activities = self._list_activities_for_range(start_time, end_time) content = journal_generation_service.generate_objective( activities=activities, todos=todos, language=payload.language, ) if journal: update_payload = JournalUpdatePayload(content_objective=content) self.repository.update(journal["id"], update_payload) return JournalGenerateResponse(content=content) def generate_ai_view(self, payload: JournalGenerateRequest) -> JournalGenerateResponse: journal, date, title, content_original, day_bucket_start = self._resolve_generation_context( payload ) start_time, end_time = self._resolve_day_bucket_range(date, day_bucket_start) todos = self._list_todos_for_range(start_time, end_time) activities = self._list_activities_for_range(start_time, end_time) content = journal_generation_service.generate_ai_view( title=title, content_original=content_original, activities=activities, todos=todos, language=payload.language, ) if journal: update_payload = JournalUpdatePayload(content_ai=content) self.repository.update(journal["id"], update_payload) return JournalGenerateResponse(content=content) ================================================ FILE: lifetrace/services/todo_service.py ================================================ """Todo 业务逻辑层 处理 Todo 相关的业务逻辑,与数据访问层解耦。 """ from typing import Any from fastapi import HTTPException from lifetrace.jobs.deadline_reminder import refresh_todo_reminders, remove_todo_reminder_jobs from lifetrace.repositories.interfaces import ITodoRepository from lifetrace.schemas.todo import TodoAttachmentResponse, TodoCreate, TodoResponse, TodoUpdate from lifetrace.storage.notification_storage import ( clear_dismissed_mark, clear_notification_by_todo_id, ) from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() def _to_ical_status(status: str | None) -> str | None: if not status: return None mapping = { "active": "NEEDS-ACTION", "completed": "COMPLETED", "canceled": "CANCELLED", "draft": "NEEDS-ACTION", } return mapping.get(status, "NEEDS-ACTION") def _normalize_item_type(item_type: str | None) -> str: return (item_type or "VTODO").upper() class TodoService: """Todo 业务逻辑层""" def __init__(self, repository: ITodoRepository): self.repository = repository def get_todo(self, todo_id: int) -> TodoResponse: """获取单个 Todo""" todo = self.repository.get_by_id(todo_id) if not todo: raise HTTPException(status_code=404, detail="todo 不存在") return TodoResponse(**todo) def get_todo_by_uid(self, uid: str) -> TodoResponse | None: """根据 UID 获取单个 Todo""" todo = self.repository.get_by_uid(uid) return TodoResponse(**todo) if todo else None def list_todos(self, limit: int, offset: int, status: str | None) -> dict[str, Any]: """获取 Todo 列表""" todos = self.repository.list_todos(limit, offset, status) total = self.repository.count(status) return {"total": total, "todos": [TodoResponse(**t) for t in todos]} def create_todo(self, data: TodoCreate) -> TodoResponse: """创建 Todo""" dtstart = data.dtstart or data.start_time or data.deadline or data.due dtend = data.dtend or data.end_time due = data.due or data.deadline duration = data.duration if duration and (due or dtend): raise HTTPException( status_code=400, detail="duration 与 due/dtend 互斥,请只保留一个", ) start_time = data.start_time or dtstart end_time = data.end_time or dtend deadline = data.deadline or due item_type = _normalize_item_type(data.item_type) summary = data.summary or data.name tzid = data.tzid or data.time_zone now = get_utc_now() created = data.created or now last_modified = data.last_modified or now dtstamp = data.dtstamp or now ical_status = data.ical_status or _to_ical_status( data.status.value if data.status else None ) todo_id = self.repository.create( uid=data.uid, name=data.name, summary=summary, description=data.description, user_notes=data.user_notes, parent_todo_id=data.parent_todo_id, item_type=item_type, location=data.location, categories=data.categories, classification=data.classification, deadline=deadline, start_time=start_time, end_time=end_time, dtstart=dtstart, dtend=dtend, due=due, duration=duration, time_zone=data.time_zone, tzid=tzid, is_all_day=data.is_all_day, dtstamp=dtstamp, created=created, last_modified=last_modified, sequence=data.sequence, rdate=data.rdate, exdate=data.exdate, recurrence_id=data.recurrence_id, related_to_uid=data.related_to_uid, related_to_reltype=data.related_to_reltype, ical_status=ical_status, reminder_offsets=data.reminder_offsets, status=data.status.value if data.status else "active", priority=data.priority.value if data.priority else "none", completed_at=data.completed_at, percent_complete=data.percent_complete, rrule=data.rrule, order=data.order, tags=data.tags, related_activities=data.related_activities, ) if not todo_id: raise HTTPException(status_code=500, detail="创建 todo 失败") todo = self.get_todo(todo_id) try: refresh_todo_reminders(todo) except Exception as e: logger.warning(f"创建待办后同步提醒失败: {e}") return todo def update_todo(self, todo_id: int, data: TodoUpdate) -> TodoResponse: # noqa: C901, PLR0912, PLR0915 """更新 Todo""" # 检查是否存在 if not self.repository.get_by_id(todo_id): raise HTTPException(status_code=404, detail="todo 不存在") # 提取有效字段(只更新请求中携带的字段) fields_set = ( getattr(data, "model_fields_set", None) or getattr(data, "__fields_set__", None) or set() ) kwargs = {field: getattr(data, field) for field in fields_set} existing = self.repository.get_by_id(todo_id) item_type = _normalize_item_type( kwargs.get("item_type") or (existing.get("item_type") if existing else None) ) # 枚举转字符串 if "status" in kwargs and kwargs["status"] is not None: kwargs["status"] = kwargs["status"].value if "priority" in kwargs and kwargs["priority"] is not None: kwargs["priority"] = kwargs["priority"].value if "item_type" in kwargs and kwargs["item_type"] is not None: kwargs["item_type"] = _normalize_item_type(kwargs["item_type"]) if "summary" not in kwargs and "name" in kwargs: kwargs["summary"] = kwargs["name"] if "name" not in kwargs and "summary" in kwargs: kwargs["name"] = kwargs["summary"] if ( "summary" in kwargs and "name" in kwargs and kwargs["summary"] and kwargs["name"] and kwargs["summary"] != kwargs["name"] ): kwargs["name"] = kwargs["summary"] if "tzid" not in kwargs and "time_zone" in kwargs: kwargs["tzid"] = kwargs["time_zone"] if "time_zone" not in kwargs and "tzid" in kwargs: kwargs["time_zone"] = kwargs["tzid"] if "dtstart" not in kwargs: if "start_time" in kwargs: kwargs["dtstart"] = kwargs["start_time"] elif "deadline" in kwargs: kwargs["dtstart"] = kwargs["deadline"] elif "due" in kwargs: kwargs["dtstart"] = kwargs["due"] if "start_time" not in kwargs and "dtstart" in kwargs: kwargs["start_time"] = kwargs["dtstart"] if "dtend" not in kwargs and "end_time" in kwargs: kwargs["dtend"] = kwargs["end_time"] if "end_time" not in kwargs and "dtend" in kwargs: kwargs["end_time"] = kwargs["dtend"] if "due" not in kwargs and "deadline" in kwargs: kwargs["due"] = kwargs["deadline"] if "deadline" not in kwargs and "due" in kwargs: kwargs["deadline"] = kwargs["due"] if "deadline" in kwargs and "start_time" not in kwargs: kwargs["start_time"] = kwargs["deadline"] if "duration" in kwargs and kwargs["duration"] is not None: if ("due" in kwargs and kwargs["due"] is not None) or ( "dtend" in kwargs and kwargs["dtend"] is not None ): raise HTTPException( status_code=400, detail="duration 与 due/dtend 互斥,请只保留一个", ) if item_type == "VTODO": kwargs.setdefault("due", None) kwargs.setdefault("deadline", None) else: kwargs.setdefault("dtend", None) kwargs.setdefault("end_time", None) if "ical_status" not in kwargs and "status" in kwargs: kwargs["ical_status"] = _to_ical_status(kwargs["status"]) if "last_modified" not in kwargs: kwargs["last_modified"] = get_utc_now() if "dtstamp" not in kwargs: kwargs["dtstamp"] = kwargs["last_modified"] if not self.repository.update(todo_id, **kwargs): raise HTTPException(status_code=500, detail="更新 todo 失败") schedule_fields = { "start_time", "dtstart", "due", "deadline", "reminder_offsets", "status", "item_type", } if schedule_fields.intersection(fields_set): clear_notification_by_todo_id(todo_id) clear_dismissed_mark(todo_id) todo = self.get_todo(todo_id) if schedule_fields.intersection(fields_set): try: refresh_todo_reminders(todo) except Exception as e: logger.warning(f"更新待办后同步提醒失败: {e}") return todo def delete_todo(self, todo_id: int) -> None: """删除 Todo""" if not self.repository.get_by_id(todo_id): raise HTTPException(status_code=404, detail="todo 不存在") if not self.repository.delete(todo_id): raise HTTPException(status_code=500, detail="删除 todo 失败") remove_todo_reminder_jobs(todo_id) clear_notification_by_todo_id(todo_id) clear_dismissed_mark(todo_id) def reorder_todos(self, items: list[dict[str, Any]]) -> dict[str, Any]: """批量重排序 Todo""" if not self.repository.reorder(items): raise HTTPException(status_code=500, detail="批量重排序失败") return {"success": True, "message": f"成功更新 {len(items)} 个待办的排序"} def add_attachment( self, *, todo_id: int, file_name: str, file_path: str, file_size: int | None, mime_type: str | None, file_hash: str | None, source: str = "user", ) -> TodoAttachmentResponse: if not self.repository.get_by_id(todo_id): raise HTTPException(status_code=404, detail="todo 不存在") attachment = self.repository.add_attachment( todo_id=todo_id, file_name=file_name, file_path=file_path, file_size=file_size, mime_type=mime_type, file_hash=file_hash, source=source, ) if not attachment: raise HTTPException(status_code=500, detail="创建附件失败") return TodoAttachmentResponse(**attachment) def remove_attachment(self, *, todo_id: int, attachment_id: int) -> None: if not self.repository.get_by_id(todo_id): raise HTTPException(status_code=404, detail="todo 不存在") if not self.repository.remove_attachment(todo_id=todo_id, attachment_id=attachment_id): raise HTTPException(status_code=404, detail="附件不存在或已解绑") def get_attachment(self, attachment_id: int) -> dict[str, Any]: attachment = self.repository.get_attachment(attachment_id) if not attachment: raise HTTPException(status_code=404, detail="附件不存在") return attachment ================================================ FILE: lifetrace/storage/__init__.py ================================================ """ Storage 模块 提供数据库管理和模型定义。 注意: - 该包在导入时**不应**执行数据库初始化/迁移等副作用操作。 - 需要访问 `db_base` / `*_mgr` 等对象时,采用懒加载,避免在 Alembic 迁移环境中 (`lifetrace/migrations/env.py`)导入模型时触发递归迁移。 """ from __future__ import annotations import importlib from typing import TYPE_CHECKING __all__ = [ "activity_mgr", "automation_task_mgr", "chat_mgr", "db_base", "event_mgr", "get_db", "get_session", "journal_mgr", "ocr_mgr", "screenshot_mgr", "stats_mgr", "todo_mgr", ] _LAZY_EXPORTS: set[str] = set(__all__) if TYPE_CHECKING: from lifetrace.storage.database import ( activity_mgr, automation_task_mgr, chat_mgr, db_base, event_mgr, get_db, get_session, journal_mgr, ocr_mgr, screenshot_mgr, stats_mgr, todo_mgr, ) def __getattr__(name: str): if name in _LAZY_EXPORTS: # 仅在真正需要时才触发数据库初始化 _database = importlib.import_module("lifetrace.storage.database") return getattr(_database, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def __dir__() -> list[str]: return sorted(set(globals().keys()) | _LAZY_EXPORTS) ================================================ FILE: lifetrace/storage/activity_manager.py ================================================ """活动管理器 - 负责活动相关的数据库操作""" from datetime import datetime, timedelta from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import Activity, ActivityEventRelation, Event from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger logger = get_logger() class ActivityManager: """活动管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base def create_activity( self, start_time: datetime, end_time: datetime, ai_title: str, ai_summary: str, event_ids: list[int], ) -> int | None: """创建活动记录并关联事件 Args: start_time: 活动开始时间 end_time: 活动结束时间 ai_title: AI生成的活动标题 ai_summary: AI生成的活动摘要 event_ids: 关联的事件ID列表 Returns: 活动ID,失败返回None """ try: with self.db_base.get_session() as session: # 创建活动记录 activity = Activity( start_time=start_time, end_time=end_time, ai_title=ai_title, ai_summary=ai_summary, event_count=len(event_ids), ) session.add(activity) session.flush() if activity.id is None: raise ValueError("Activity must have an id before linking events.") # 创建关联关系 for event_id in event_ids: relation = ActivityEventRelation( activity_id=activity.id, event_id=event_id, ) session.add(relation) session.commit() logger.info(f"创建活动 {activity.id}: {ai_title},包含 {len(event_ids)} 个事件") return activity.id except SQLAlchemyError as e: logger.error(f"创建活动失败: {e}") return None def get_activity(self, activity_id: int) -> dict[str, Any] | None: """获取单个活动信息 Args: activity_id: 活动ID Returns: 活动信息,不存在返回None """ try: with self.db_base.get_session() as session: activity = ( session.query(Activity) .filter(col(Activity.id) == activity_id, col(Activity.deleted_at).is_(None)) .first() ) if not activity: return None return { "id": activity.id, "start_time": activity.start_time, "end_time": activity.end_time, "ai_title": activity.ai_title, "ai_summary": activity.ai_summary, "event_count": activity.event_count, "created_at": activity.created_at, "updated_at": activity.updated_at, } except SQLAlchemyError as e: logger.error(f"获取活动信息失败: {e}") return None def get_activities( self, limit: int = 50, offset: int = 0, start_date: datetime | None = None, end_date: datetime | None = None, ) -> list[dict[str, Any]]: """查询活动列表 Args: limit: 返回数量限制 offset: 偏移量 start_date: 开始日期 end_date: 结束日期 Returns: 活动列表 """ try: with self.db_base.get_session() as session: q = session.query(Activity).filter(col(Activity.deleted_at).is_(None)) if start_date: q = q.filter(col(Activity.start_time) >= start_date) if end_date: q = q.filter(col(Activity.start_time) <= end_date) q = q.order_by(col(Activity.start_time).desc()).offset(offset).limit(limit) activities = q.all() results: list[dict[str, Any]] = [] for activity in activities: results.append( { "id": activity.id, "start_time": activity.start_time, "end_time": activity.end_time, "ai_title": activity.ai_title, "ai_summary": activity.ai_summary, "event_count": activity.event_count, "created_at": activity.created_at, "updated_at": activity.updated_at, } ) return results except SQLAlchemyError as e: logger.error(f"查询活动列表失败: {e}") return [] def count_activities( self, start_date: datetime | None = None, end_date: datetime | None = None, ) -> int: """统计活动总数""" try: with self.db_base.get_session() as session: q = session.query(Activity).filter(col(Activity.deleted_at).is_(None)) if start_date: q = q.filter(col(Activity.start_time) >= start_date) if end_date: q = q.filter(col(Activity.start_time) <= end_date) return q.count() except SQLAlchemyError as e: logger.error(f"统计活动数量失败: {e}") return 0 def get_activity_events(self, activity_id: int) -> list[int]: """获取活动关联的事件ID列表 Args: activity_id: 活动ID Returns: 事件ID列表 """ try: with self.db_base.get_session() as session: relations = ( session.query(ActivityEventRelation) .filter( col(ActivityEventRelation.activity_id) == activity_id, col(ActivityEventRelation.deleted_at).is_(None), ) .all() ) return [r.event_id for r in relations] except SQLAlchemyError as e: logger.error(f"获取活动关联事件失败: {e}") return [] def get_unprocessed_events(self, query_start_time: datetime) -> list[Event]: """查询未关联到活动的已完成且有AI总结的事件 Args: query_start_time: 查询起始时间 Returns: 事件列表 """ try: with self.db_base.get_session() as session: # 查询已完成且有AI总结的事件 events = ( session.query(Event) .filter( col(Event.end_time).isnot(None), col(Event.ai_title).isnot(None), col(Event.ai_summary).isnot(None), col(Event.start_time) >= query_start_time, col(Event.deleted_at).is_(None), ) .order_by(col(Event.start_time).asc()) .all() ) # 过滤掉已关联到活动的事件 unprocessed_events = [] for event in events: # 检查是否已关联 relation = ( session.query(ActivityEventRelation) .filter( col(ActivityEventRelation.event_id) == event.id, col(ActivityEventRelation.deleted_at).is_(None), ) .first() ) if not relation: # 在session关闭前访问所有需要的属性,确保它们被加载 # 这样可以避免在session外访问时触发refresh操作 _ = ( event.id, event.start_time, event.end_time, event.ai_title, event.ai_summary, event.app_name, event.window_title, ) # 将对象从session中分离,使其可以在session外使用 session.expunge(event) unprocessed_events.append(event) return unprocessed_events except SQLAlchemyError as e: logger.error(f"查询未处理事件失败: {e}") return [] def activity_exists_for_time_window(self, window_start: datetime, window_end: datetime) -> bool: """检查指定时间窗口是否已存在活动记录 Args: window_start: 窗口开始时间 window_end: 窗口结束时间 Returns: 是否存在 """ try: with self.db_base.get_session() as session: activity = ( session.query(Activity) .filter( col(Activity.start_time) == window_start, col(Activity.end_time) == window_end, col(Activity.deleted_at).is_(None), ) .first() ) return activity is not None except SQLAlchemyError as e: logger.error(f"检查活动是否存在失败: {e}") return False def activity_exists_for_event(self, event: Event) -> bool: """检查事件是否已关联到某个活动 Args: event: 事件对象 Returns: 是否已关联 """ try: with self.db_base.get_session() as session: relation = ( session.query(ActivityEventRelation) .filter( col(ActivityEventRelation.event_id) == event.id, col(ActivityEventRelation.deleted_at).is_(None), ) .first() ) return relation is not None except SQLAlchemyError as e: logger.error(f"检查事件是否已关联失败: {e}") return False def activity_exists_for_event_id(self, event_id: int) -> bool: """检查事件ID是否已关联到某个活动 Args: event_id: 事件ID Returns: 是否已关联 """ try: with self.db_base.get_session() as session: relation = ( session.query(ActivityEventRelation) .filter( col(ActivityEventRelation.event_id) == event_id, col(ActivityEventRelation.deleted_at).is_(None), ) .first() ) return relation is not None except SQLAlchemyError as e: logger.error(f"检查事件ID是否已关联失败: {e}") return False def activity_overlaps_with_event(self, event: Event, tolerance_seconds: int = 60) -> bool: """检查是否存在与事件时间范围重叠的活动记录 Args: event: 事件对象 tolerance_seconds: 容忍的时间差(秒),用于处理边界情况 Returns: 是否存在重叠 """ try: if not event.end_time: return False with self.db_base.get_session() as session: # 查询与事件时间范围有重叠的活动 # 重叠条件:活动的开始时间 < 事件的结束时间 + 容忍度 # 且活动的结束时间 > 事件的开始时间 - 容忍度 activities = ( session.query(Activity) .filter( col(Activity.start_time) < event.end_time + timedelta(seconds=tolerance_seconds), col(Activity.end_time) > event.start_time - timedelta(seconds=tolerance_seconds), col(Activity.deleted_at).is_(None), ) .all() ) return len(activities) > 0 except SQLAlchemyError as e: logger.error(f"检查活动重叠失败: {e}") return False ================================================ FILE: lifetrace/storage/automation_task_manager.py ================================================ """自动化任务管理器 - 负责用户自定义任务的数据库操作""" from __future__ import annotations from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.models import AutomationTask from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() _UNSET = object() class AutomationTaskManager: """自动化任务管理类""" def __init__(self, db_base): self.db_base = db_base def list_tasks(self, *, enabled: bool | None = None) -> list[dict[str, Any]]: try: with self.db_base.get_session() as session: q = session.query(AutomationTask).filter(col(AutomationTask.deleted_at).is_(None)) if enabled is not None: q = q.filter(col(AutomationTask.enabled) == enabled) q = q.order_by(col(AutomationTask.created_at).desc()) tasks = q.all() return [self._to_dict(task) for task in tasks] except SQLAlchemyError as exc: logger.error("查询自动化任务失败: %s", exc) return [] def get_task(self, task_id: int) -> dict[str, Any] | None: try: with self.db_base.get_session() as session: task = ( session.query(AutomationTask) .filter( col(AutomationTask.id) == task_id, col(AutomationTask.deleted_at).is_(None), ) .first() ) if not task: return None return self._to_dict(task) except SQLAlchemyError as exc: logger.error("获取自动化任务失败: %s", exc) return None def create_task( self, *, name: str, description: str | None, enabled: bool, schedule_type: str, schedule_config: str | None, action_type: str, action_payload: str | None, ) -> int | None: try: with self.db_base.get_session() as session: task = AutomationTask( name=name, description=description, enabled=enabled, schedule_type=schedule_type, schedule_config=schedule_config, action_type=action_type, action_payload=action_payload, ) session.add(task) session.flush() if task.id is None: raise ValueError("AutomationTask must have an id after creation.") logger.info("创建自动化任务: %s - %s", task.id, task.name) return task.id except SQLAlchemyError as exc: logger.error("创建自动化任务失败: %s", exc) return None def update_task( # noqa: PLR0913 self, task_id: int, *, name: str | Any = _UNSET, description: str | None | Any = _UNSET, enabled: bool | Any = _UNSET, schedule_type: str | Any = _UNSET, schedule_config: str | None | Any = _UNSET, action_type: str | Any = _UNSET, action_payload: str | None | Any = _UNSET, last_run_at: Any = _UNSET, last_status: str | None | Any = _UNSET, last_error: str | None | Any = _UNSET, last_output: str | None | Any = _UNSET, ) -> bool: try: with self.db_base.get_session() as session: task = ( session.query(AutomationTask) .filter( col(AutomationTask.id) == task_id, col(AutomationTask.deleted_at).is_(None), ) .first() ) if not task: return False updates = { "name": name, "description": description, "enabled": enabled, "schedule_type": schedule_type, "schedule_config": schedule_config, "action_type": action_type, "action_payload": action_payload, "last_run_at": last_run_at, "last_status": last_status, "last_error": last_error, "last_output": last_output, } for attr, value in updates.items(): if value is not _UNSET: setattr(task, attr, value) task.updated_at = get_utc_now() session.flush() logger.info("更新自动化任务: %s", task_id) return True except SQLAlchemyError as exc: logger.error("更新自动化任务失败: %s", exc) return False def delete_task(self, task_id: int) -> bool: try: with self.db_base.get_session() as session: task = ( session.query(AutomationTask) .filter( col(AutomationTask.id) == task_id, col(AutomationTask.deleted_at).is_(None), ) .first() ) if not task: return False task.deleted_at = get_utc_now() task.updated_at = get_utc_now() session.flush() logger.info("删除自动化任务: %s", task_id) return True except SQLAlchemyError as exc: logger.error("删除自动化任务失败: %s", exc) return False @staticmethod def _to_dict(task: AutomationTask) -> dict[str, Any]: return { "id": task.id, "name": task.name, "description": task.description, "enabled": task.enabled, "schedule_type": task.schedule_type, "schedule_config": task.schedule_config, "action_type": task.action_type, "action_payload": task.action_payload, "last_run_at": task.last_run_at, "last_status": task.last_status, "last_error": task.last_error, "last_output": task.last_output, "created_at": task.created_at, "updated_at": task.updated_at, } ================================================ FILE: lifetrace/storage/chat_manager.py ================================================ """聊天管理器 - 负责聊天会话和消息相关的数据库操作""" from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import Chat, Message from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 聊天标题最大长度 CHAT_TITLE_MAX_LENGTH = 50 class ChatManager: """聊天管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base def create_chat( self, session_id: str, chat_type: str = "event", title: str | None = None, context_id: int | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: """创建聊天会话 Args: session_id: 会话ID(UUID) chat_type: 聊天类型(event, project, general, task等) title: 会话标题 context_id: 上下文ID(根据chat_type不同而不同) metadata: JSON格式的元数据 """ try: with self.db_base.get_session() as session: chat = Chat( session_id=session_id, chat_type=chat_type, title=title, context_id=context_id, extra_data=metadata, ) session.add(chat) session.flush() logger.info(f"创建聊天会话: {session_id}, 类型: {chat_type}") return { "id": chat.id, "session_id": chat.session_id, "chat_type": chat.chat_type, "title": chat.title, "context_id": chat.context_id, "extra_data": chat.extra_data, "created_at": chat.created_at, "updated_at": chat.updated_at, "last_message_at": chat.last_message_at, } except SQLAlchemyError as e: logger.error(f"创建聊天会话失败: {e}") return None def get_chat_by_session_id(self, session_id: str) -> dict[str, Any] | None: """根据session_id获取聊天会话""" try: with self.db_base.get_session() as session: chat = session.query(Chat).filter_by(session_id=session_id).first() if chat: return { "id": chat.id, "session_id": chat.session_id, "chat_type": chat.chat_type, "title": chat.title, "context_id": chat.context_id, "extra_data": chat.extra_data, "created_at": chat.created_at, "updated_at": chat.updated_at, "last_message_at": chat.last_message_at, } return None except SQLAlchemyError as e: logger.error(f"获取聊天会话失败: {e}") return None def list_chats( self, chat_type: str | None = None, limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: """列出聊天会话 Args: chat_type: 聊天类型过滤(可选) limit: 返回数量限制 offset: 偏移量 """ try: with self.db_base.get_session() as session: q = session.query(Chat) if chat_type: q = q.filter(col(Chat.chat_type) == chat_type) chats = ( q.order_by( col(Chat.last_message_at).desc().nullslast(), col(Chat.created_at).desc(), ) .offset(offset) .limit(limit) .all() ) return [ { "id": c.id, "session_id": c.session_id, "chat_type": c.chat_type, "title": c.title, "context_id": c.context_id, "extra_data": c.extra_data, "created_at": c.created_at, "updated_at": c.updated_at, "last_message_at": c.last_message_at, } for c in chats ] except SQLAlchemyError as e: logger.error(f"列出聊天会话失败: {e}") return [] def update_chat_title(self, session_id: str, title: str) -> bool: """更新聊天会话标题""" try: with self.db_base.get_session() as session: chat = session.query(Chat).filter_by(session_id=session_id).first() if chat: chat.title = title session.flush() logger.info(f"更新聊天会话标题: {session_id} -> {title}") return True return False except SQLAlchemyError as e: logger.error(f"更新聊天会话标题失败: {e}") return False def delete_chat(self, session_id: str) -> bool: """删除聊天会话及其所有消息""" try: with self.db_base.get_session() as session: chat = session.query(Chat).filter_by(session_id=session_id).first() if chat: # 删除该会话的所有消息 session.query(Message).filter_by(chat_id=chat.id).delete() # 删除会话 session.delete(chat) session.flush() logger.info(f"删除聊天会话: {session_id}") return True return False except SQLAlchemyError as e: logger.error(f"删除聊天会话失败: {e}") return False # ===== 消息管理 ===== def add_message( self, session_id: str, role: str, content: str, token_count: int | None = None, model: str | None = None, metadata: str | None = None, ) -> dict[str, Any] | None: """添加消息到聊天会话 Args: session_id: 会话ID role: 消息角色(user, assistant, system) content: 消息内容 token_count: token数量 model: 使用的模型 metadata: JSON格式的元数据 """ try: with self.db_base.get_session() as session: # 获取或创建聊天会话 chat = session.query(Chat).filter_by(session_id=session_id).first() if not chat: # 如果会话不存在,自动创建 chat = Chat( session_id=session_id, chat_type="event", # 默认类型 ) session.add(chat) session.flush() logger.info(f"自动创建聊天会话: {session_id}") if chat.id is None: raise ValueError("Chat must have an id before adding messages.") # 添加消息 message = Message( chat_id=chat.id, role=role, content=content, token_count=token_count, model=model, extra_data=metadata, ) session.add(message) # 更新会话的最后消息时间 chat.last_message_at = get_utc_now() # 如果会话没有标题且这是第一条用户消息,可以设置标题 if not chat.title and role == "user": # 使用消息内容的前N个字符作为标题 chat.title = content[:CHAT_TITLE_MAX_LENGTH] + ( "..." if len(content) > CHAT_TITLE_MAX_LENGTH else "" ) session.flush() logger.info(f"添加消息到会话 {session_id}: role={role}") return { "id": message.id, "chat_id": message.chat_id, "role": message.role, "content": message.content, "token_count": message.token_count, "model": message.model, "extra_data": message.extra_data, "created_at": message.created_at, } except SQLAlchemyError as e: logger.error(f"添加消息失败: {e}") return None def get_messages( self, session_id: str, limit: int | None = None, offset: int = 0, ) -> list[dict[str, Any]]: """获取聊天会话的消息列表 Args: session_id: 会话ID limit: 返回数量限制(None表示全部) offset: 偏移量 """ try: with self.db_base.get_session() as session: chat = session.query(Chat).filter_by(session_id=session_id).first() if not chat: return [] q = ( session.query(Message) .filter_by(chat_id=chat.id) .order_by(col(Message.created_at).asc()) ) if offset > 0: q = q.offset(offset) if limit: q = q.limit(limit) messages = q.all() return [ { "id": m.id, "chat_id": m.chat_id, "role": m.role, "content": m.content, "token_count": m.token_count, "model": m.model, "extra_data": m.extra_data, "created_at": m.created_at, } for m in messages ] except SQLAlchemyError as e: logger.error(f"获取消息列表失败: {e}") return [] def get_message_count(self, session_id: str) -> int: """获取聊天会话的消息数量""" try: with self.db_base.get_session() as session: chat = session.query(Chat).filter_by(session_id=session_id).first() if not chat: return 0 return session.query(Message).filter_by(chat_id=chat.id).count() except SQLAlchemyError as e: logger.error(f"获取消息数量失败: {e}") return 0 def get_chat_summaries( self, chat_type: str | None = None, limit: int = 10, ) -> list[dict[str, Any]]: """获取聊天会话摘要列表(包含消息数量) Args: chat_type: 聊天类型过滤(可选) limit: 返回数量限制 """ try: with self.db_base.get_session() as session: q = session.query(Chat) if chat_type: q = q.filter(col(Chat.chat_type) == chat_type) chats = ( q.order_by( col(Chat.last_message_at).desc().nullslast(), col(Chat.created_at).desc(), ) .limit(limit) .all() ) summaries = [] for chat in chats: message_count = session.query(Message).filter_by(chat_id=chat.id).count() summaries.append( { "session_id": chat.session_id, "chat_type": chat.chat_type, "title": chat.title, "context_id": chat.context_id, "created_at": chat.created_at, "last_active": chat.last_message_at or chat.created_at, "message_count": message_count, } ) return summaries except SQLAlchemyError as e: logger.error(f"获取聊天会话摘要失败: {e}") return [] # ===== 会话上下文管理 ===== def get_chat_context(self, session_id: str) -> str | None: """获取会话上下文(JSON 字符串) Args: session_id: 会话ID Returns: 上下文 JSON 字符串,如果不存在则返回 None """ try: with self.db_base.get_session() as session: chat = session.query(Chat).filter_by(session_id=session_id).first() if chat: return chat.context return None except SQLAlchemyError as e: logger.error(f"获取会话上下文失败: {e}") return None def update_chat_context(self, session_id: str, context: str) -> bool: """更新会话上下文 Args: session_id: 会话ID context: JSON 格式的上下文字符串 Returns: 是否更新成功 """ try: with self.db_base.get_session() as session: chat = session.query(Chat).filter_by(session_id=session_id).first() if chat: chat.context = context chat.updated_at = get_utc_now() session.flush() return True else: # 如果会话不存在,自动创建 chat = Chat( session_id=session_id, chat_type="general", context=context, ) session.add(chat) session.flush() logger.info(f"自动创建会话并设置上下文: {session_id}") return True except SQLAlchemyError as e: logger.error(f"更新会话上下文失败: {e}") return False ================================================ FILE: lifetrace/storage/database.py ================================================ """ 数据库管理器主入口 - 直接暴露各个功能管理器 """ from lifetrace.storage.activity_manager import ActivityManager from lifetrace.storage.automation_task_manager import AutomationTaskManager from lifetrace.storage.chat_manager import ChatManager from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.event_manager import EventManager from lifetrace.storage.journal_manager import JournalManager from lifetrace.storage.ocr_manager import OCRManager from lifetrace.storage.screenshot_manager import ScreenshotManager from lifetrace.storage.stats_manager import StatsManager from lifetrace.storage.todo_manager import TodoManager from lifetrace.util.logging_config import get_logger logger = get_logger() # ===== 初始化数据库基础 ===== db_base = DatabaseBase() # ===== 初始化各个功能管理器 ===== screenshot_mgr = ScreenshotManager(db_base) event_mgr = EventManager(db_base) ocr_mgr = OCRManager(db_base) todo_mgr = TodoManager(db_base) chat_mgr = ChatManager(db_base) stats_mgr = StatsManager(db_base) journal_mgr = JournalManager(db_base) activity_mgr = ActivityManager(db_base) automation_task_mgr = AutomationTaskManager(db_base) # ===== 向后兼容:保留原有的接口 ===== engine = db_base.engine SessionLocal = db_base.SessionLocal def get_session(): """获取数据库会话上下文管理器""" return db_base.get_session() # 数据库会话生成器(用于依赖注入) def get_db(): """获取数据库会话的生成器函数""" if SessionLocal is None: raise RuntimeError("Database session factory is not initialized.") session = SessionLocal() try: yield session finally: session.close() ================================================ FILE: lifetrace/storage/database_base.py ================================================ """数据库基础管理器 - 负责数据库初始化和会话管理 使用 SQLModel 进行数据库管理,迁移由 Alembic 处理。 """ import os from contextlib import contextmanager from pathlib import Path from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from sqlmodel import Session, SQLModel from lifetrace.util.logging_config import get_logger from lifetrace.util.path_utils import get_database_path from lifetrace.util.utils import ensure_dir logger = get_logger() try: from alembic import command from alembic.config import Config except Exception: command = None Config = None class DatabaseBase: """数据库基础管理类 - 处理数据库初始化和会话管理""" def __init__(self): self.engine = None self.SessionLocal = None self._init_database() def _init_database(self): """初始化数据库""" try: db_path = str(get_database_path()) # 检查数据库文件是否已存在 db_exists = os.path.exists(db_path) # 确保数据库目录存在 ensure_dir(os.path.dirname(db_path)) # 创建引擎 self.engine = create_engine("sqlite:///" + db_path, echo=False, pool_pre_ping=True) # 创建会话工厂(兼容旧代码) self.SessionLocal = sessionmaker(bind=self.engine) # 创建表 # 对于新数据库:创建所有表 # 对于现有数据库:只创建缺失的表(SQLModel.metadata.create_all 会自动跳过已存在的表) if not db_exists: SQLModel.metadata.create_all(bind=self.engine) logger.info(f"数据库初始化完成: {db_path}") else: # 对于现有数据库,也调用 create_all 来创建缺失的表 # checkfirst=True(默认值)会跳过已存在的表 SQLModel.metadata.create_all(bind=self.engine) # 运行 Alembic 迁移,补齐已有数据库的新增列/索引 self._run_migrations() # 性能优化:添加关键索引 self._create_performance_indexes() except Exception as e: logger.error(f"数据库初始化失败: {e}") raise def _run_migrations(self) -> None: """运行 Alembic 迁移(如可用)""" if command is None or Config is None: logger.warning("Alembic 未就绪,跳过迁移") return alembic_ini = Path(__file__).resolve().parents[1] / "alembic.ini" migrations_dir = alembic_ini.parent / "migrations" if not alembic_ini.exists() or not migrations_dir.exists(): logger.warning("Alembic 配置缺失,跳过迁移") return config = Config(str(alembic_ini)) config.set_main_option("script_location", str(migrations_dir)) config.set_main_option("sqlalchemy.url", f"sqlite:///{get_database_path()}") try: command.upgrade(config, "head") logger.info("数据库迁移检查完成") except Exception as exc: logger.error(f"数据库迁移失败: {exc}") raise def _create_performance_indexes(self): """创建性能优化索引""" try: if self.engine is None: raise RuntimeError("Database engine is not initialized.") with self.engine.connect() as conn: # 获取现有索引列表(只获取索引名称) existing_indexes = [ row[0] for row in conn.execute( text( "SELECT name FROM sqlite_master WHERE type='index' AND name IS NOT NULL" ) ).fetchall() ] # 获取所有表的列信息,用于检查列是否存在 table_columns: dict[str, set[str]] = {} tables = conn.execute( text("SELECT name FROM sqlite_master WHERE type='table'") ).fetchall() for (table_name,) in tables: columns = conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall() table_columns[table_name] = {col[1] for col in columns} # 定义需要创建的索引 # 格式:(索引名, 表名, 列名列表, 创建SQL) indexes_to_create = [ ( "idx_ocr_results_screenshot_id", "ocr_results", ["screenshot_id"], "CREATE INDEX IF NOT EXISTS idx_ocr_results_screenshot_id ON ocr_results(screenshot_id)", ), ( "idx_screenshots_created_at", "screenshots", ["created_at"], "CREATE INDEX IF NOT EXISTS idx_screenshots_created_at ON screenshots(created_at)", ), ( "idx_screenshots_app_name", "screenshots", ["app_name"], "CREATE INDEX IF NOT EXISTS idx_screenshots_app_name ON screenshots(app_name)", ), ( "idx_screenshots_event_id", "screenshots", ["event_id"], "CREATE INDEX IF NOT EXISTS idx_screenshots_event_id ON screenshots(event_id)", ), ( "idx_todos_parent_todo_id", "todos", ["parent_todo_id"], "CREATE INDEX IF NOT EXISTS idx_todos_parent_todo_id ON todos(parent_todo_id)", ), ( "idx_todos_status", "todos", ["status"], "CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status)", ), ( "idx_todos_deleted_at", "todos", ["deleted_at"], "CREATE INDEX IF NOT EXISTS idx_todos_deleted_at ON todos(deleted_at)", ), ( "idx_todos_priority", "todos", ["priority"], "CREATE INDEX IF NOT EXISTS idx_todos_priority ON todos(priority)", ), ( "idx_todos_uid", "todos", ["uid"], "CREATE INDEX IF NOT EXISTS idx_todos_uid ON todos(uid)", ), ( "idx_todos_order", "todos", ["order"], 'CREATE INDEX IF NOT EXISTS idx_todos_order ON todos("order")', ), ( "idx_attachments_file_hash", "attachments", ["file_hash"], "CREATE INDEX IF NOT EXISTS idx_attachments_file_hash ON attachments(file_hash)", ), ( "idx_attachments_deleted_at", "attachments", ["deleted_at"], "CREATE INDEX IF NOT EXISTS idx_attachments_deleted_at ON attachments(deleted_at)", ), ( "idx_todo_attachment_relations_todo_id", "todo_attachment_relations", ["todo_id"], "CREATE INDEX IF NOT EXISTS idx_todo_attachment_relations_todo_id ON todo_attachment_relations(todo_id)", ), ( "idx_todo_attachment_relations_attachment_id", "todo_attachment_relations", ["attachment_id"], "CREATE INDEX IF NOT EXISTS idx_todo_attachment_relations_attachment_id ON todo_attachment_relations(attachment_id)", ), ( "idx_tags_tag_name_unique", "tags", ["tag_name"], "CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_tag_name_unique ON tags(tag_name)", ), ( "idx_tags_deleted_at", "tags", ["deleted_at"], "CREATE INDEX IF NOT EXISTS idx_tags_deleted_at ON tags(deleted_at)", ), ( "idx_todo_tag_relations_todo_id", "todo_tag_relations", ["todo_id"], "CREATE INDEX IF NOT EXISTS idx_todo_tag_relations_todo_id ON todo_tag_relations(todo_id)", ), ( "idx_todo_tag_relations_tag_id", "todo_tag_relations", ["tag_id"], "CREATE INDEX IF NOT EXISTS idx_todo_tag_relations_tag_id ON todo_tag_relations(tag_id)", ), ( "idx_journals_date", "journals", ["date"], "CREATE INDEX IF NOT EXISTS idx_journals_date ON journals(date)", ), ( "idx_journals_deleted_at", "journals", ["deleted_at"], "CREATE INDEX IF NOT EXISTS idx_journals_deleted_at ON journals(deleted_at)", ), ( "idx_journals_uid", "journals", ["uid"], "CREATE INDEX IF NOT EXISTS idx_journals_uid ON journals(uid)", ), ( "idx_journal_tag_relations_journal_id", "journal_tag_relations", ["journal_id"], "CREATE INDEX IF NOT EXISTS idx_journal_tag_relations_journal_id ON journal_tag_relations(journal_id)", ), ( "idx_journal_tag_relations_tag_id", "journal_tag_relations", ["tag_id"], "CREATE INDEX IF NOT EXISTS idx_journal_tag_relations_tag_id ON journal_tag_relations(tag_id)", ), ( "idx_journal_todo_relations_journal_id", "journal_todo_relations", ["journal_id"], "CREATE INDEX IF NOT EXISTS idx_journal_todo_relations_journal_id ON journal_todo_relations(journal_id)", ), ( "idx_journal_todo_relations_todo_id", "journal_todo_relations", ["todo_id"], "CREATE INDEX IF NOT EXISTS idx_journal_todo_relations_todo_id ON journal_todo_relations(todo_id)", ), ( "idx_journal_activity_relations_journal_id", "journal_activity_relations", ["journal_id"], "CREATE INDEX IF NOT EXISTS idx_journal_activity_relations_journal_id ON journal_activity_relations(journal_id)", ), ( "idx_journal_activity_relations_activity_id", "journal_activity_relations", ["activity_id"], "CREATE INDEX IF NOT EXISTS idx_journal_activity_relations_activity_id ON journal_activity_relations(activity_id)", ), ( "idx_activities_start_time", "activities", ["start_time"], "CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time)", ), ( "idx_activities_end_time", "activities", ["end_time"], "CREATE INDEX IF NOT EXISTS idx_activities_end_time ON activities(end_time)", ), ( "idx_activity_event_relations_activity_id", "activity_event_relations", ["activity_id"], "CREATE INDEX IF NOT EXISTS idx_activity_event_relations_activity_id ON activity_event_relations(activity_id)", ), ( "idx_activity_event_relations_event_id", "activity_event_relations", ["event_id"], "CREATE INDEX IF NOT EXISTS idx_activity_event_relations_event_id ON activity_event_relations(event_id)", ), ( "idx_chats_session_id", "chats", ["session_id"], "CREATE INDEX IF NOT EXISTS idx_chats_session_id ON chats(session_id)", ), ( "idx_messages_chat_id", "messages", ["chat_id"], "CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id)", ), # 音频相关索引 ( "idx_audio_recordings_start_time", "audio_recordings", ["start_time"], "CREATE INDEX IF NOT EXISTS idx_audio_recordings_start_time ON audio_recordings(start_time)", ), ( "idx_audio_recordings_status", "audio_recordings", ["status"], "CREATE INDEX IF NOT EXISTS idx_audio_recordings_status ON audio_recordings(status)", ), ( "idx_audio_recordings_deleted_at", "audio_recordings", ["deleted_at"], "CREATE INDEX IF NOT EXISTS idx_audio_recordings_deleted_at ON audio_recordings(deleted_at)", ), ( "idx_transcriptions_audio_recording_id", "transcriptions", ["audio_recording_id"], "CREATE INDEX IF NOT EXISTS idx_transcriptions_audio_recording_id ON transcriptions(audio_recording_id)", ), ( "idx_transcriptions_extraction_status", "transcriptions", ["extraction_status"], "CREATE INDEX IF NOT EXISTS idx_transcriptions_extraction_status ON transcriptions(extraction_status)", ), ] # 创建索引 created_count = 0 skipped_count = 0 for index_name, table_name, columns, create_sql in indexes_to_create: # 检查索引是否已存在 if index_name in existing_indexes: continue # 检查表是否存在 if table_name not in table_columns: skipped_count += 1 logger.debug(f"跳过索引 {index_name}:表 {table_name} 不存在") continue # 检查所有需要的列是否存在 missing_columns = [ col for col in columns if col not in table_columns[table_name] ] if missing_columns: skipped_count += 1 logger.debug( f"跳过索引 {index_name}:列 {missing_columns} 在表 {table_name} 中不存在" ) continue # 创建索引 conn.execute(text(create_sql)) created_count += 1 logger.info(f"已创建性能索引: {index_name}") conn.commit() # 只在有索引被创建或跳过时打印完成信息 if created_count > 0 or skipped_count > 0: logger.info( f"性能索引检查完成:创建 {created_count} 个,跳过 {skipped_count} 个(表/列不存在)" ) except Exception as e: logger.warning(f"创建性能索引失败: {e}") raise @contextmanager def get_session(self): """获取数据库会话上下文管理器(使用 SQLModel Session)""" with Session(self.engine) as session: try: yield session session.commit() except Exception as e: session.rollback() logger.error(f"数据库操作失败: {e}") raise @contextmanager def get_sqlalchemy_session(self): """获取 SQLAlchemy 会话上下文管理器(用于兼容旧代码)""" if self.SessionLocal is None: raise RuntimeError("Database session factory is not initialized.") session = self.SessionLocal() try: yield session session.commit() except Exception as e: session.rollback() logger.error(f"数据库操作失败: {e}") raise finally: session.close() # 数据库会话生成器(用于依赖注入) def get_db(db_base: DatabaseBase): """获取数据库会话的生成器函数""" if db_base.SessionLocal is None: raise RuntimeError("Database session factory is not initialized.") session = db_base.SessionLocal() try: yield session finally: session.close() ================================================ FILE: lifetrace/storage/event_manager.py ================================================ """事件管理器 - 负责事件相关的数据库操作""" import importlib from datetime import datetime from typing import Any from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import Event, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now from .event_queries import ( count_events, get_event_id_by_screenshot, get_event_screenshots, get_event_summary, get_event_text, get_events_by_ids, list_events, search_events_simple, ) from .event_stats import get_app_usage_stats logger = get_logger() class EventManager: """事件管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base def _get_last_open_event(self, session: Session) -> Event | None: """获取最后一个未结束的事件""" return ( session.query(Event) .filter(col(Event.end_time).is_(None)) .order_by(col(Event.start_time).desc()) .first() ) def _should_reuse_event( self, old_app: str | None, old_title: str | None, new_app: str | None, new_title: str | None, ) -> bool: """判断是否应该复用事件""" old_app_norm = (old_app or "").strip().lower() new_app_norm = (new_app or "").strip().lower() old_title_norm = (old_title or "").strip() new_title_norm = (new_title or "").strip() if old_app_norm != new_app_norm: logger.info(f"🔄 应用切换: {old_app} → {new_app} (创建新事件)") return False if old_title_norm != new_title_norm: logger.info(f"📝 窗口标题变化: {old_title} → {new_title} (创建新事件)") return False logger.info("♻️ 应用名和窗口标题都相同,复用事件") return True def get_active_event(self) -> int | None: """获取当前活跃的事件ID""" try: with self.db_base.get_session() as session: last_event = self._get_last_open_event(session) if last_event: return last_event.id return None except SQLAlchemyError as e: logger.error(f"获取活跃事件失败: {e}") return None def get_or_create_event( self, app_name: str | None, window_title: str | None, timestamp: datetime | None = None, ) -> int | None: """按当前前台应用和窗口标题维护事件""" try: closed_event_id = None with self.db_base.get_session() as session: now_ts = timestamp or get_utc_now() last_event = self._get_last_open_event(session) if last_event: logger.info( f"🔍 检查事件复用 - 旧事件ID: {last_event.id}, " f"旧应用: '{last_event.app_name}', 新应用: '{app_name}', " f"旧标题: '{last_event.window_title}', 新标题: '{window_title}'" ) should_reuse = self._should_reuse_event( old_app=last_event.app_name, old_title=last_event.window_title, new_app=app_name, new_title=window_title, ) logger.info(f"📊 事件复用判断结果: {should_reuse}") if should_reuse: session.flush() logger.info(f"♻️ 复用事件 {last_event.id}(不关闭)") return last_event.id else: last_event.end_time = now_ts closed_event_id = last_event.id session.flush() logger.info( f"🔚 关闭旧事件 {closed_event_id}: {last_event.app_name} - {last_event.window_title}" ) else: logger.info("❌ 没有找到未结束的事件,需要创建新事件") new_event = Event(app_name=app_name, window_title=window_title, start_time=now_ts) session.add(new_event) session.flush() new_event_id = new_event.id logger.info( f"✨ 创建新事件 {new_event_id}: {app_name} - {window_title} (end_time=NULL)" ) if closed_event_id: try: logger.info(f"📝 触发已关闭事件 {closed_event_id} 的摘要生成") summary_module = importlib.import_module("lifetrace.llm.event_summary_service") summary_module.generate_event_summary_async(closed_event_id) except Exception as e: logger.error(f"触发事件摘要生成失败: {e}") else: logger.info(f"✅ 无需生成摘要(新事件 {new_event_id},无旧事件关闭)") return new_event_id except SQLAlchemyError as e: logger.error(f"获取或创建事件失败: {e}") return None def close_active_event(self, end_time: datetime | None = None) -> bool: """主动结束当前事件""" try: closed_event_id = None with self.db_base.get_session() as session: last_event = self._get_last_open_event(session) if last_event and last_event.end_time is None: last_event.end_time = end_time or get_utc_now() closed_event_id = last_event.id session.flush() if closed_event_id: try: summary_module = importlib.import_module("lifetrace.llm.event_summary_service") summary_module.generate_event_summary_async(closed_event_id) except Exception as e: logger.error(f"触发事件摘要生成失败: {e}") return closed_event_id is not None except SQLAlchemyError as e: logger.error(f"结束事件失败: {e}") return False def update_event_summary(self, event_id: int, ai_title: str, ai_summary: str) -> bool: """更新事件的AI生成标题和摘要""" try: with self.db_base.get_session() as session: event = session.query(Event).filter(col(Event.id) == event_id).first() if event: event.ai_title = ai_title event.ai_summary = ai_summary session.commit() logger.info(f"事件 {event_id} AI摘要更新成功") return True else: logger.warning(f"事件 {event_id} 不存在") return False except SQLAlchemyError as e: logger.error(f"更新事件AI摘要失败: {e}") return False def get_active_event_by_app(self, app_name: str) -> int | None: """获取指定应用的活跃事件ID""" try: with self.db_base.get_session() as session: event = ( session.query(Event) .filter( col(Event.app_name) == app_name, col(Event.status).in_(["new", "processing"]), ) .order_by(col(Event.start_time).desc()) .first() ) return event.id if event else None except SQLAlchemyError as e: logger.error(f"获取活跃事件失败: {e}") return None def create_event_for_screenshot( self, screenshot_id: int, app_name: str, window_title: str, timestamp: datetime, ) -> int | None: """为截图创建新事件""" try: with self.db_base.get_session() as session: new_event = Event( app_name=app_name, window_title=window_title, start_time=timestamp, status="new", ) session.add(new_event) session.flush() screenshot = ( session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first() ) if screenshot: screenshot.event_id = new_event.id session.flush() logger.info(f"✨ 创建新事件 {new_event.id}: {app_name} (status=new)") return new_event.id except SQLAlchemyError as e: logger.error(f"创建事件失败: {e}") return None def add_screenshot_to_event(self, screenshot_id: int, event_id: int) -> bool: """将截图添加到指定事件""" try: with self.db_base.get_session() as session: screenshot = ( session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first() ) if not screenshot: logger.warning(f"截图 {screenshot_id} 不存在") return False event = session.query(Event).filter(col(Event.id) == event_id).first() if not event: logger.warning(f"事件 {event_id} 不存在") return False screenshot.event_id = event_id if event.status == "new": event.status = "processing" session.flush() logger.debug( f"截图 {screenshot_id} 已添加到事件 {event_id},事件状态: {event.status}" ) return True except SQLAlchemyError as e: logger.error(f"添加截图到事件失败: {e}") return False def complete_event(self, event_id: int, end_time: datetime) -> bool: """完成事件""" try: with self.db_base.get_session() as session: event = session.query(Event).filter(col(Event.id) == event_id).first() if not event: logger.warning(f"事件 {event_id} 不存在") return False event.status = "done" event.end_time = end_time session.flush() logger.info(f"🔚 完成事件 {event_id}: {event.app_name} (status=done)") try: logger.info(f"📝 触发已完成事件 {event_id} 的摘要生成") summary_module = importlib.import_module("lifetrace.llm.event_summary_service") summary_module.generate_event_summary_async(event_id) except Exception as e: logger.error(f"触发事件摘要生成失败: {e}") return True except SQLAlchemyError as e: logger.error(f"完成事件失败: {e}") return False # 委托给 event_queries 模块的方法 def list_events( self, limit: int = 50, offset: int = 0, start_date: datetime | None = None, end_date: datetime | None = None, app_name: str | None = None, ) -> list[dict[str, Any]]: """列出事件摘要""" return list_events(self.db_base, limit, offset, start_date, end_date, app_name) def count_events( self, start_date: datetime | None = None, end_date: datetime | None = None, app_name: str | None = None, ) -> int: """统计事件总数""" return count_events(self.db_base, start_date, end_date, app_name) def get_event_screenshots(self, event_id: int) -> list[dict[str, Any]]: """获取事件内截图列表""" return get_event_screenshots(self.db_base, event_id) def search_events_simple( self, query: str | None, start_date: datetime | None = None, end_date: datetime | None = None, app_name: str | None = None, limit: int = 50, ) -> list[dict[str, Any]]: """搜索事件""" return search_events_simple(self.db_base, query, start_date, end_date, app_name, limit) def get_event_summary(self, event_id: int) -> dict[str, Any] | None: """获取单个事件的摘要信息""" return get_event_summary(self.db_base, event_id) def get_events_by_ids(self, event_ids: list[int]) -> list[dict[str, Any]]: """批量获取事件的摘要信息""" return get_events_by_ids(self.db_base, event_ids) def get_event_id_by_screenshot(self, screenshot_id: int) -> int | None: """根据截图ID获取所属事件ID""" return get_event_id_by_screenshot(self.db_base, screenshot_id) def get_event_text(self, event_id: int) -> str: """聚合事件下所有截图的OCR文本内容""" return get_event_text(self.db_base, event_id) # 委托给 event_stats 模块的方法 def get_app_usage_stats( self, days: int | None = None, start_date: datetime | None = None, end_date: datetime | None = None, ) -> dict[str, Any]: """获取应用使用统计""" return get_app_usage_stats(self.db_base, days, start_date, end_date) ================================================ FILE: lifetrace/storage/event_queries.py ================================================ """ 事件查询模块 包含事件查询和搜索相关方法 """ from datetime import datetime from typing import Any from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import Event, OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger logger = get_logger() def list_events( db_base: DatabaseBase, limit: int = 50, offset: int = 0, start_date: datetime | None = None, end_date: datetime | None = None, app_name: str | None = None, ) -> list[dict[str, Any]]: """列出事件摘要(包含首张截图ID与截图数量)""" try: with db_base.get_session() as session: q = session.query(Event) if start_date: q = q.filter(col(Event.start_time) >= start_date) if end_date: q = q.filter(col(Event.start_time) <= end_date) if app_name: q = q.filter(col(Event.app_name).like(f"%{app_name}%")) q = q.order_by(col(Event.start_time).desc()).offset(offset).limit(limit) events = q.all() results: list[dict[str, Any]] = [] for ev in events: first_shot = ( session.query(Screenshot) .filter(col(Screenshot.event_id) == ev.id) .order_by(col(Screenshot.created_at).asc()) .first() ) shot_count = ( session.query(Screenshot).filter(col(Screenshot.event_id) == ev.id).count() ) results.append( { "id": ev.id, "app_name": ev.app_name, "window_title": ev.window_title, "start_time": ev.start_time, "end_time": ev.end_time, "screenshot_count": shot_count, "first_screenshot_id": (first_shot.id if first_shot else None), "ai_title": ev.ai_title, "ai_summary": ev.ai_summary, } ) return results except SQLAlchemyError as e: logger.error(f"列出事件失败: {e}") return [] def count_events( db_base: DatabaseBase, start_date: datetime | None = None, end_date: datetime | None = None, app_name: str | None = None, ) -> int: """统计事件总数""" try: with db_base.get_session() as session: q = session.query(Event) if start_date: q = q.filter(col(Event.start_time) >= start_date) if end_date: q = q.filter(col(Event.start_time) <= end_date) if app_name: q = q.filter(col(Event.app_name).like(f"%{app_name}%")) return q.count() except SQLAlchemyError as e: logger.error(f"统计事件总数失败: {e}") return 0 def get_event_screenshots(db_base: DatabaseBase, event_id: int) -> list[dict[str, Any]]: """获取事件内截图列表""" try: with db_base.get_session() as session: shots = ( session.query(Screenshot) .filter(col(Screenshot.event_id) == event_id) .order_by(col(Screenshot.created_at).asc()) .all() ) return [ { "id": s.id, "file_path": s.file_path, "app_name": s.app_name, "window_title": s.window_title, "created_at": s.created_at, "width": s.width, "height": s.height, } for s in shots ] except SQLAlchemyError as e: logger.error(f"获取事件截图失败: {e}") return [] def search_events_simple( db_base: DatabaseBase, query: str | None, start_date: datetime | None = None, end_date: datetime | None = None, app_name: str | None = None, limit: int = 50, ) -> list[dict[str, Any]]: """基于SQLite的简单事件搜索""" try: with db_base.get_session() as session: base_sql = """ SELECT e.id AS event_id, e.app_name AS app_name, e.window_title AS window_title, e.start_time AS start_time, e.end_time AS end_time, e.ai_title AS ai_title, e.ai_summary AS ai_summary, MIN(s.id) AS first_screenshot_id, COUNT(s.id) AS screenshot_count FROM events e JOIN screenshots s ON s.event_id = e.id LEFT JOIN ocr_results o ON o.screenshot_id = s.id """ where_clause = [] params: dict[str, Any] = {} if query and query.strip(): where_clause.append( "(e.window_title LIKE :q OR e.ai_title LIKE :q OR e.ai_summary LIKE :q OR o.text_content LIKE :q)" ) params["q"] = f"%{query}%" if start_date: where_clause.append("e.start_time >= :start_date") params["start_date"] = start_date if end_date: where_clause.append("e.start_time <= :end_date") params["end_date"] = end_date if app_name: where_clause.append("e.app_name LIKE :app_name") params["app_name"] = f"%{app_name}%" sql = base_sql if where_clause: sql += " WHERE " + " AND ".join(where_clause) sql += " GROUP BY e.id ORDER BY e.start_time DESC LIMIT :limit" params["limit"] = limit logger.info(f"执行搜索SQL: {sql}") logger.info(f"参数: {params}") rows = session.execute(text(sql), params).fetchall() results = [] for r in rows: results.append( { "id": r.event_id, "app_name": r.app_name, "window_title": r.window_title, "start_time": r.start_time, "end_time": r.end_time, "ai_title": r.ai_title, "ai_summary": r.ai_summary, "first_screenshot_id": r.first_screenshot_id, "screenshot_count": r.screenshot_count, } ) return results except SQLAlchemyError as e: logger.error(f"搜索事件失败: {e}") return [] def get_event_summary(db_base: DatabaseBase, event_id: int) -> dict[str, Any] | None: """获取单个事件的摘要信息""" try: with db_base.get_session() as session: ev = session.query(Event).filter(col(Event.id) == event_id).first() if not ev: return None first_shot = ( session.query(Screenshot) .filter(col(Screenshot.event_id) == ev.id) .order_by(col(Screenshot.created_at).asc()) .first() ) shot_count = session.query(Screenshot).filter(col(Screenshot.event_id) == ev.id).count() return { "id": ev.id, "app_name": ev.app_name, "window_title": ev.window_title, "start_time": ev.start_time, "end_time": ev.end_time, "screenshot_count": shot_count, "first_screenshot_id": first_shot.id if first_shot else None, "ai_title": ev.ai_title, "ai_summary": ev.ai_summary, } except SQLAlchemyError as e: logger.error(f"获取事件摘要失败: {e}") return None def get_events_by_ids(db_base: DatabaseBase, event_ids: list[int]) -> list[dict[str, Any]]: """批量获取事件的摘要信息""" if not event_ids: return [] try: with db_base.get_session() as session: events = session.query(Event).filter(col(Event.id).in_(event_ids)).all() if not events: return [] event_map = {ev.id: ev for ev in events} results = [] for event_id in event_ids: ev = event_map.get(event_id) if not ev: continue first_shot = ( session.query(Screenshot) .filter(col(Screenshot.event_id) == ev.id) .order_by(col(Screenshot.created_at).asc()) .first() ) shot_count = ( session.query(Screenshot).filter(col(Screenshot.event_id) == ev.id).count() ) results.append( { "id": ev.id, "app_name": ev.app_name, "window_title": ev.window_title, "start_time": ev.start_time, "end_time": ev.end_time, "screenshot_count": shot_count, "first_screenshot_id": first_shot.id if first_shot else None, "ai_title": ev.ai_title, "ai_summary": ev.ai_summary, } ) return results except SQLAlchemyError as e: logger.error(f"批量获取事件摘要失败: {e}") return [] def get_event_id_by_screenshot(db_base: DatabaseBase, screenshot_id: int) -> int | None: """根据截图ID获取所属事件ID""" try: with db_base.get_session() as session: s = session.query(Screenshot).filter(col(Screenshot.id) == screenshot_id).first() return int(s.event_id) if s and s.event_id is not None else None except SQLAlchemyError as e: logger.error(f"查询截图所属事件失败: {e}") return None def get_event_text(db_base: DatabaseBase, event_id: int) -> str: """聚合事件下所有截图的OCR文本内容""" try: with db_base.get_session() as session: ocr_list = ( session.query(OCRResult) .join(Screenshot, col(OCRResult.screenshot_id) == col(Screenshot.id)) .filter(col(Screenshot.event_id) == event_id) .order_by(col(OCRResult.created_at).asc()) .all() ) texts = [o.text_content for o in ocr_list if o and o.text_content] return "\n".join(texts) except SQLAlchemyError as e: logger.error(f"聚合事件文本失败: {e}") return "" ================================================ FILE: lifetrace/storage/event_stats.py ================================================ """ 事件统计模块 包含应用使用统计相关方法 """ from datetime import datetime, timedelta from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import Event from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() def get_app_usage_stats( db_base: DatabaseBase, days: int | None = None, start_date: datetime | None = None, end_date: datetime | None = None, ) -> dict[str, Any]: """基于 Event 表获取应用使用统计数据 相比 AppUsageLog 表,使用 Event 表统计有以下优势: 1. 更准确:使用真实的 start_time 和 end_time 计算持续时间 2. 数据量更小:不需要每次截图都记录 3. 逻辑更简单:减少冗余表和存储逻辑 Args: db_base: 数据库基类实例 days: 统计最近多少天(默认7天) start_date: 开始日期 end_date: 结束日期 Returns: 包含应用使用统计的字典 """ try: with db_base.get_session() as session: # 计算时间范围 if start_date and end_date: dt_start = start_date dt_end = end_date + timedelta(days=1) - timedelta(seconds=1) else: dt_end = get_utc_now() use_days = days if days else 7 dt_start = dt_end - timedelta(days=use_days) # 查询已结束的事件 events = ( session.query(Event) .filter( col(Event.start_time) >= dt_start, col(Event.start_time) <= dt_end, col(Event.end_time).isnot(None), ) .all() ) # 聚合统计数据 app_usage_summary = {} daily_usage = {} hourly_usage = {} for event in events: app_name = event.app_name if not app_name: continue duration = (event.end_time - event.start_time).total_seconds() date_str = event.start_time.strftime("%Y-%m-%d") hour = event.start_time.hour # 应用使用汇总 if app_name not in app_usage_summary: app_usage_summary[app_name] = { "app_name": app_name, "total_time": 0, "session_count": 0, "last_used": event.end_time, } app_usage_summary[app_name]["total_time"] += duration app_usage_summary[app_name]["session_count"] += 1 app_usage_summary[app_name]["last_used"] = max( app_usage_summary[app_name]["last_used"], event.end_time ) # 每日使用统计 if date_str not in daily_usage: daily_usage[date_str] = {} if app_name not in daily_usage[date_str]: daily_usage[date_str][app_name] = 0 daily_usage[date_str][app_name] += duration # 小时使用统计 if hour not in hourly_usage: hourly_usage[hour] = {} if app_name not in hourly_usage[hour]: hourly_usage[hour][app_name] = 0 hourly_usage[hour][app_name] += duration return { "app_usage_summary": app_usage_summary, "daily_usage": daily_usage, "hourly_usage": hourly_usage, "total_apps": len(app_usage_summary), "total_time": sum(app["total_time"] for app in app_usage_summary.values()), } except SQLAlchemyError as e: logger.error(f"从Event表获取应用使用统计失败: {e}") return { "app_usage_summary": {}, "daily_usage": {}, "hourly_usage": {}, "total_apps": 0, "total_time": 0, } ================================================ FILE: lifetrace/storage/journal_manager.py ================================================ """日记管理器 - 负责日记及标签关联的数据库操作""" from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import ( Journal, JournalActivityRelation, JournalTagRelation, JournalTodoRelation, Tag, ) from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() _UNSET = object() @dataclass(frozen=True) class JournalCreatePayload: """创建日记的聚合参数""" name: str user_notes: str date: datetime uid: str | None = None content_format: str = "markdown" content_objective: str | None = None content_ai: str | None = None mood: str | None = None energy: int | None = None day_bucket_start: datetime | None = None tags: list[str] | None = None related_todo_ids: list[int] | None = None related_activity_ids: list[int] | None = None @dataclass(frozen=True) class JournalUpdatePayload: """更新日记的聚合参数""" name: str | Any = _UNSET user_notes: str | Any = _UNSET date: datetime | Any = _UNSET content_format: str | Any = _UNSET content_objective: str | None | Any = _UNSET content_ai: str | None | Any = _UNSET mood: str | None | Any = _UNSET energy: int | None | Any = _UNSET day_bucket_start: datetime | None | Any = _UNSET tags: list[str] | None | Any = _UNSET related_todo_ids: list[int] | None | Any = _UNSET related_activity_ids: list[int] | None | Any = _UNSET class JournalManager: """日记管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base # ===== 工具方法 ===== def _serialize_journal( self, journal: Journal, tags: Iterable[Tag] | None = None, related_todo_ids: list[int] | None = None, related_activity_ids: list[int] | None = None, ) -> dict[str, Any]: tag_list = [{"id": t.id, "tag_name": t.tag_name} for t in tags] if tags else [] return { "id": journal.id, "uid": journal.uid, "name": journal.name, "user_notes": journal.user_notes, "date": journal.date, "content_format": journal.content_format or "markdown", "content_objective": journal.content_objective, "content_ai": journal.content_ai, "mood": journal.mood, "energy": journal.energy, "day_bucket_start": journal.day_bucket_start, "created_at": journal.created_at, "updated_at": journal.updated_at, "deleted_at": journal.deleted_at, "tags": tag_list, "related_todo_ids": related_todo_ids or [], "related_activity_ids": related_activity_ids or [], } def _get_tags_for_journal(self, session, journal_id: int) -> list[Tag]: """获取日记关联的标签""" return ( session.query(Tag) .join(JournalTagRelation, col(JournalTagRelation.tag_id) == col(Tag.id)) .filter(col(JournalTagRelation.journal_id) == journal_id) .filter(col(Tag.deleted_at).is_(None)) .all() ) def _get_related_todo_ids(self, session, journal_id: int) -> list[int]: return [ rel.todo_id for rel in session.query(JournalTodoRelation) .filter(col(JournalTodoRelation.journal_id) == journal_id) .filter(col(JournalTodoRelation.deleted_at).is_(None)) .all() ] def _get_related_activity_ids(self, session, journal_id: int) -> list[int]: return [ rel.activity_id for rel in session.query(JournalActivityRelation) .filter(col(JournalActivityRelation.journal_id) == journal_id) .filter(col(JournalActivityRelation.deleted_at).is_(None)) .all() ] def _replace_tags(self, session, journal_id: int, tags: list[str] | None) -> None: """替换日记标签关联""" session.query(JournalTagRelation).filter_by(journal_id=journal_id).delete( synchronize_session=False ) if not tags: return cleaned: list[str] = [] seen: set[str] = set() for tag_name in tags: name = (tag_name or "").strip() if not name or name in seen: continue seen.add(name) cleaned.append(name) for tag_name in cleaned: tag = session.query(Tag).filter_by(tag_name=tag_name).first() if not tag: tag = Tag(tag_name=tag_name) session.add(tag) session.flush() if tag.id is None: raise ValueError("Tag must have an id before creating relation.") session.add(JournalTagRelation(journal_id=journal_id, tag_id=tag.id)) def _replace_related_todos(self, session, journal_id: int, todo_ids: list[int] | None) -> None: session.query(JournalTodoRelation).filter_by(journal_id=journal_id).delete( synchronize_session=False ) if not todo_ids: return for todo_id in dict.fromkeys(todo_ids): session.add(JournalTodoRelation(journal_id=journal_id, todo_id=todo_id)) def _replace_related_activities( self, session, journal_id: int, activity_ids: list[int] | None ) -> None: session.query(JournalActivityRelation).filter_by(journal_id=journal_id).delete( synchronize_session=False ) if not activity_ids: return for activity_id in dict.fromkeys(activity_ids): session.add(JournalActivityRelation(journal_id=journal_id, activity_id=activity_id)) def _apply_journal_updates(self, journal: Journal, payload: JournalUpdatePayload) -> None: if payload.content_format is not _UNSET: journal.content_format = payload.content_format or "markdown" updates = { "name": payload.name, "user_notes": payload.user_notes, "date": payload.date, "content_objective": payload.content_objective, "content_ai": payload.content_ai, "mood": payload.mood, "energy": payload.energy, "day_bucket_start": payload.day_bucket_start, } for attr, value in updates.items(): if value is not _UNSET: setattr(journal, attr, value) # ===== CRUD 接口 ===== def create_journal(self, payload: JournalCreatePayload) -> int | None: """创建日记""" try: with self.db_base.get_session() as session: journal_data = { "name": payload.name, "user_notes": payload.user_notes, "date": payload.date, "content_format": payload.content_format or "markdown", "content_objective": payload.content_objective, "content_ai": payload.content_ai, "mood": payload.mood, "energy": payload.energy, "day_bucket_start": payload.day_bucket_start, } if payload.uid: journal_data["uid"] = payload.uid journal = Journal(**journal_data) session.add(journal) session.flush() if journal.id is None: raise ValueError("Journal must have an id before linking relations.") # 处理标签与关联 self._replace_tags(session, journal.id, payload.tags) self._replace_related_todos(session, journal.id, payload.related_todo_ids) self._replace_related_activities(session, journal.id, payload.related_activity_ids) logger.info(f"创建日记成功: {journal.id} - {payload.name}") return journal.id except SQLAlchemyError as e: logger.error(f"创建日记失败: {e}") return None def get_journal(self, journal_id: int) -> dict[str, Any] | None: """获取单个日记""" try: with self.db_base.get_session() as session: journal = ( session.query(Journal) .filter(col(Journal.id) == journal_id) .filter(col(Journal.deleted_at).is_(None)) .first() ) if not journal: return None tags = self._get_tags_for_journal(session, journal.id) related_todo_ids = self._get_related_todo_ids(session, journal.id) related_activity_ids = self._get_related_activity_ids(session, journal.id) return self._serialize_journal( journal, tags, related_todo_ids=related_todo_ids, related_activity_ids=related_activity_ids, ) except SQLAlchemyError as e: logger.error(f"获取日记失败: {e}") return None def list_journals( self, *, limit: int = 100, offset: int = 0, start_date=None, end_date=None, ) -> list[dict[str, Any]]: """列出日记""" try: with self.db_base.get_session() as session: query = session.query(Journal).filter(col(Journal.deleted_at).is_(None)) if start_date is not None: query = query.filter(col(Journal.date) >= start_date) if end_date is not None: query = query.filter(col(Journal.date) <= end_date) journals = ( query.order_by(col(Journal.date).desc(), col(Journal.created_at).desc()) .offset(offset) .limit(limit) .all() ) results = [] for journal in journals: tags = self._get_tags_for_journal(session, journal.id) related_todo_ids = self._get_related_todo_ids(session, journal.id) related_activity_ids = self._get_related_activity_ids(session, journal.id) results.append( self._serialize_journal( journal, tags, related_todo_ids=related_todo_ids, related_activity_ids=related_activity_ids, ) ) return results except SQLAlchemyError as e: logger.error(f"列出日记失败: {e}") return [] def count_journals(self, start_date=None, end_date=None) -> int: """统计日记数量""" try: with self.db_base.get_session() as session: query = session.query(Journal).filter(col(Journal.deleted_at).is_(None)) if start_date is not None: query = query.filter(col(Journal.date) >= start_date) if end_date is not None: query = query.filter(col(Journal.date) <= end_date) return query.count() except SQLAlchemyError as e: logger.error(f"统计日记数量失败: {e}") return 0 def update_journal(self, journal_id: int, payload: JournalUpdatePayload) -> bool: """更新日记""" try: with self.db_base.get_session() as session: journal = ( session.query(Journal) .filter(col(Journal.id) == journal_id) .filter(col(Journal.deleted_at).is_(None)) .first() ) if not journal: logger.warning(f"日记不存在: {journal_id}") return False self._apply_journal_updates(journal, payload) if payload.tags is not _UNSET: self._replace_tags(session, journal_id, payload.tags) if payload.related_todo_ids is not _UNSET: self._replace_related_todos(session, journal_id, payload.related_todo_ids) if payload.related_activity_ids is not _UNSET: self._replace_related_activities( session, journal_id, payload.related_activity_ids ) journal.updated_at = get_utc_now() session.flush() logger.info(f"更新日记: {journal_id}") return True except SQLAlchemyError as e: logger.error(f"更新日记失败: {e}") return False def delete_journal(self, journal_id: int) -> bool: """删除日记(物理删除)""" try: with self.db_base.get_session() as session: journal = session.query(Journal).filter_by(id=journal_id).first() if not journal: logger.warning(f"日记不存在: {journal_id}") return False # 删除标签关联 session.query(JournalTagRelation).filter_by(journal_id=journal_id).delete( synchronize_session=False ) session.query(JournalTodoRelation).filter_by(journal_id=journal_id).delete( synchronize_session=False ) session.query(JournalActivityRelation).filter_by(journal_id=journal_id).delete( synchronize_session=False ) session.delete(journal) session.flush() logger.info(f"删除日记: {journal_id}") return True except SQLAlchemyError as e: logger.error(f"删除日记失败: {e}") return False ================================================ FILE: lifetrace/storage/migrations/journal_migration.py ================================================ """日记表迁移/初始化脚本 运行此脚本可在现有数据库上创建 journals 与 journal_tag_relations 表,并补充相关索引。 """ from typing import Any, cast from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import ( Journal, JournalActivityRelation, JournalTagRelation, JournalTodoRelation, ) from lifetrace.util.logging_config import get_logger logger = get_logger() def migrate(): """创建缺失表并刷新性能索引""" db_base = DatabaseBase() if db_base.engine is None: raise RuntimeError("Database engine is not initialized.") with db_base.engine.begin() as conn: cast("Any", Journal).__table__.create(bind=conn, checkfirst=True) cast("Any", JournalTagRelation).__table__.create(bind=conn, checkfirst=True) cast("Any", JournalTodoRelation).__table__.create(bind=conn, checkfirst=True) cast("Any", JournalActivityRelation).__table__.create(bind=conn, checkfirst=True) logger.info("journals 相关表检查/创建完成") # 补充索引 db_base._create_performance_indexes() logger.info("journals 相关索引检查/创建完成") if __name__ == "__main__": migrate() ================================================ FILE: lifetrace/storage/models.py ================================================ """SQLModel 数据模型定义 使用 SQLModel 重写所有数据模型,保持与现有数据库表结构兼容。 """ # pyright: reportIncompatibleVariableOverride=false from datetime import datetime from typing import ClassVar from uuid import uuid4 from sqlmodel import Column, Field, SQLModel, Text from lifetrace.util.time_utils import get_utc_now def get_utc_time(): """获取 UTC 时间(timezone-aware)""" return get_utc_now() # ========== 混入类 ========== class TimestampMixin(SQLModel): """时间戳混入类""" created_at: datetime = Field(default_factory=get_utc_time) updated_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None # ========== 核心业务模型 ========== class Screenshot(TimestampMixin, table=True): """截图记录模型""" __tablename__: ClassVar[str] = "screenshots" id: int | None = Field(default=None, primary_key=True) file_path: str = Field(max_length=500, unique=True) # 文件路径 file_hash: str = Field(max_length=64) # 文件hash值 file_size: int # 文件大小 file_deleted: bool = False # 文件是否已被清理 width: int # 截图宽度 height: int # 截图高度 screen_id: int = 0 # 屏幕ID app_name: str | None = Field(default=None, max_length=200) # 前台应用名称 window_title: str | None = Field(default=None, max_length=500) # 窗口标题 event_id: int | None = None # 关联事件ID is_processed: bool = False # 是否在进行OCR处理 processed_at: datetime | None = None # OCR处理完成时间 def __repr__(self): return f"" class OCRResult(TimestampMixin, table=True): """OCR结果模型""" __tablename__: ClassVar[str] = "ocr_results" id: int | None = Field(default=None, primary_key=True) screenshot_id: int # 关联截图ID text_content: str | None = Field(default=None, sa_column=Column(Text)) # 提取的文本内容 confidence: float | None = None # 置信度[0, 1] language: str | None = Field(default=None, max_length=10) # 识别语言 processing_time: float | None = None # OCR处理耗时(秒) text_hash: str | None = Field( default=None, max_length=64, index=True, ) # 文本内容的哈希值,用于去重和缓存 def __repr__(self): return f"" class Event(TimestampMixin, table=True): """事件模型(按前台应用连续使用区间聚合截图)""" __tablename__: ClassVar[str] = "events" id: int | None = Field(default=None, primary_key=True) app_name: str | None = Field(default=None, max_length=200) # 前台应用名称 window_title: str | None = Field(default=None, max_length=500) # 首个或最近的窗口标题 start_time: datetime = Field(default_factory=get_utc_time) # 事件开始时间 end_time: datetime | None = None # 事件结束时间 status: str = Field(default="new", max_length=20) # 事件状态:new, processing, done ai_title: str | None = Field(default=None, max_length=50) # LLM生成的事件标题 ai_summary: str | None = Field(default=None, sa_column=Column(Text)) # LLM生成的事件摘要 def __repr__(self): return f"" class Todo(TimestampMixin, table=True): """待办事项模型""" __tablename__: ClassVar[str] = "todos" id: int | None = Field(default=None, primary_key=True) uid: str = Field( default_factory=lambda: str(uuid4()), max_length=64, index=True ) # iCalendar UID name: str = Field(max_length=200) # 待办名称 summary: str | None = Field(default=None, max_length=200) # iCalendar SUMMARY description: str | None = Field(default=None, sa_column=Column(Text)) # 描述 user_notes: str | None = Field(default=None, sa_column=Column(Text)) # 用户笔记 parent_todo_id: int | None = None # 父级待办ID(自关联) item_type: str = Field(default="VTODO", max_length=10) # iCalendar VTODO/VEVENT location: str | None = Field(default=None, max_length=200) # iCalendar LOCATION categories: str | None = Field(default=None, sa_column=Column(Text)) # iCalendar CATEGORIES classification: str | None = Field(default=None, max_length=20) # iCalendar CLASS deadline: datetime | None = None # 截止时间(旧字段,逐步废弃) start_time: datetime | None = None # 开始时间 end_time: datetime | None = None # 结束时间 dtstart: datetime | None = None # iCalendar DTSTART dtend: datetime | None = None # iCalendar DTEND due: datetime | None = None # iCalendar DUE duration: str | None = Field(default=None, max_length=64) # iCalendar DURATION (ISO 8601) time_zone: str | None = Field(default=None, max_length=64) # 时区(IANA) tzid: str | None = Field(default=None, max_length=64) # iCalendar TZID is_all_day: bool = Field(default=False) # 是否全天 dtstamp: datetime | None = None # iCalendar DTSTAMP created: datetime | None = None # iCalendar CREATED last_modified: datetime | None = None # iCalendar LAST-MODIFIED sequence: int = Field(default=0) # iCalendar SEQUENCE rdate: str | None = Field(default=None, sa_column=Column(Text)) # iCalendar RDATE exdate: str | None = Field(default=None, sa_column=Column(Text)) # iCalendar EXDATE recurrence_id: datetime | None = None # iCalendar RECURRENCE-ID related_to_uid: str | None = Field(default=None, max_length=64) # iCalendar RELATED-TO UID related_to_reltype: str | None = Field( default=None, max_length=20 ) # iCalendar RELATED-TO RELTYPE ical_status: str | None = Field(default=None, max_length=20) # iCalendar STATUS reminder_offsets: str | None = Field( default=None, sa_column=Column(Text) ) # 提醒偏移列表(分钟) status: str = Field(default="active", max_length=20) # active/completed/canceled priority: str = Field(default="none", max_length=20) # high/medium/low/none completed_at: datetime | None = None # 完成时间(iCalendar COMPLETED) percent_complete: int = Field(default=0, ge=0, le=100) # 完成百分比(PERCENT-COMPLETE) rrule: str | None = Field(default=None, max_length=500) # iCalendar RRULE order: int = 0 # 同级待办之间的展示排序 related_activities: str | None = Field( default=None, sa_column=Column(Text) ) # 关联活动ID的JSON数组 def __repr__(self): return f"" class AutomationTask(TimestampMixin, table=True): """用户自定义自动化任务""" __tablename__: ClassVar[str] = "automation_tasks" id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=200) description: str | None = Field(default=None, sa_column=Column(Text)) enabled: bool = Field(default=True) schedule_type: str = Field(max_length=20) schedule_config: str | None = Field(default=None, sa_column=Column(Text)) action_type: str = Field(max_length=50) action_payload: str | None = Field(default=None, sa_column=Column(Text)) last_run_at: datetime | None = None last_status: str | None = Field(default=None, max_length=20) last_error: str | None = Field(default=None, sa_column=Column(Text)) last_output: str | None = Field(default=None, sa_column=Column(Text)) def __repr__(self): return f"" class Attachment(TimestampMixin, table=True): """附件信息模型""" __tablename__: ClassVar[str] = "attachments" id: int | None = Field(default=None, primary_key=True) file_path: str = Field(max_length=500) # 本地持久化路径 file_name: str = Field(max_length=200) # 文件名 file_size: int | None = None # 文件大小(字节) mime_type: str | None = Field(default=None, max_length=100) # MIME类型 file_hash: str | None = Field(default=None, max_length=64) # 去重hash def __repr__(self): return f"" class TodoAttachmentRelation(SQLModel, table=True): """待办与附件的多对多关联关系""" __tablename__: ClassVar[str] = "todo_attachment_relations" id: int | None = Field(default=None, primary_key=True) todo_id: int # 关联的待办ID attachment_id: int # 关联的附件ID source: str = Field(default="user", max_length=20) # user/ai created_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None def __repr__(self): return f"" class Tag(SQLModel, table=True): """标签模型""" __tablename__: ClassVar[str] = "tags" id: int | None = Field(default=None, primary_key=True) tag_name: str = Field(max_length=50, unique=True) # 标签名称 created_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None def __repr__(self): return f"" class TodoTagRelation(SQLModel, table=True): """待办与标签的多对多关联关系""" __tablename__: ClassVar[str] = "todo_tag_relations" id: int | None = Field(default=None, primary_key=True) todo_id: int # 关联的待办ID tag_id: int # 关联的标签ID created_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None def __repr__(self): return f"" class Journal(TimestampMixin, table=True): """日记模型""" __tablename__: ClassVar[str] = "journals" id: int | None = Field(default=None, primary_key=True) uid: str = Field( default_factory=lambda: str(uuid4()), max_length=64, index=True ) # iCalendar UID name: str = Field(max_length=200) # 日记标题 user_notes: str = Field(sa_column=Column(Text)) # 富文本内容 date: datetime # 日记日期 content_format: str = Field(default="markdown", max_length=20) # 内容格式 content_objective: str | None = Field(default=None, sa_column=Column(Text)) # 客观记录 content_ai: str | None = Field(default=None, sa_column=Column(Text)) # AI 视角 mood: str | None = Field(default=None, max_length=50) # 情绪 energy: int | None = None # 精力 day_bucket_start: datetime | None = None # 日记归属的刷新点时间 def __repr__(self): return f"" class JournalTagRelation(SQLModel, table=True): """日记与标签的多对多关联关系""" __tablename__: ClassVar[str] = "journal_tag_relations" id: int | None = Field(default=None, primary_key=True) journal_id: int # 关联的日记ID tag_id: int # 关联的标签ID created_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None def __repr__(self): return f"" class JournalTodoRelation(SQLModel, table=True): """日记与待办的关联关系""" __tablename__: ClassVar[str] = "journal_todo_relations" id: int | None = Field(default=None, primary_key=True) journal_id: int # 关联的日记ID todo_id: int # 关联的待办ID created_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None def __repr__(self): return f"" class JournalActivityRelation(SQLModel, table=True): """日记与活动的关联关系""" __tablename__: ClassVar[str] = "journal_activity_relations" id: int | None = Field(default=None, primary_key=True) journal_id: int # 关联的日记ID activity_id: int # 关联的活动ID created_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None def __repr__(self): return ( f"" ) class Chat(TimestampMixin, table=True): """聊天会话模型""" __tablename__: ClassVar[str] = "chats" id: int | None = Field(default=None, primary_key=True) session_id: str = Field(max_length=100, unique=True) # 会话ID chat_type: str | None = Field(default=None, max_length=50) # 聊天类型 title: str | None = Field(default=None, max_length=200) # 会话标题 context_id: int | None = None # 关联的上下文ID extra_data: str | None = Field(default=None, sa_column=Column(Text)) # 额外数据(JSON格式) context: str | None = Field(default=None, sa_column=Column(Text)) # 会话上下文(JSON格式) last_message_at: datetime | None = None # 最后一条消息的时间 def __repr__(self): return f"" class Message(TimestampMixin, table=True): """消息模型""" __tablename__: ClassVar[str] = "messages" id: int | None = Field(default=None, primary_key=True) chat_id: int # 关联的聊天会话ID role: str = Field(max_length=20) # 消息角色:user, assistant, system content: str = Field(sa_column=Column(Text)) # 消息内容 token_count: int | None = None # token数量 model: str | None = Field(default=None, max_length=100) # 使用的模型名称 extra_data: str | None = Field(default=None, sa_column=Column(Text)) # 额外数据 def __repr__(self): return f"" class TokenUsage(TimestampMixin, table=True): """Token使用量记录模型""" __tablename__: ClassVar[str] = "token_usage" id: int | None = Field(default=None, primary_key=True) model: str = Field(max_length=100) # 使用的模型名称 input_tokens: int # 输入token数量 output_tokens: int # 输出token数量 total_tokens: int # 总token数量 endpoint: str | None = Field(default=None, max_length=200) # API端点 response_type: str | None = Field(default=None, max_length=50) # 响应类型 feature_type: str | None = Field(default=None, max_length=50) # 功能类型 user_query_preview: str | None = Field(default=None, sa_column=Column(Text)) # 用户查询预览 query_length: int | None = None # 查询长度 input_cost: float | None = None # 输入成本(元) output_cost: float | None = None # 输出成本(元) total_cost: float | None = None # 总成本(元) def __repr__(self): return f"" class Activity(TimestampMixin, table=True): """活动模型(聚合15分钟内的事件)""" __tablename__: ClassVar[str] = "activities" id: int | None = Field(default=None, primary_key=True) start_time: datetime # 活动开始时间 end_time: datetime # 活动结束时间 ai_title: str | None = Field(default=None, max_length=100) # LLM生成的活动标题 ai_summary: str | None = Field(default=None, sa_column=Column(Text)) # LLM生成的活动摘要 event_count: int = 0 # 包含的事件数量 def __repr__(self): return f"" class ActivityEventRelation(SQLModel, table=True): """活动与事件的关联关系表""" __tablename__: ClassVar[str] = "activity_event_relations" id: int | None = Field(default=None, primary_key=True) activity_id: int # 关联的活动ID event_id: int # 关联的事件ID created_at: datetime = Field(default_factory=get_utc_time) deleted_at: datetime | None = None # 软删除时间戳 def __repr__(self): return f"" class AudioRecording(TimestampMixin, table=True): """音频录制记录模型""" __tablename__: ClassVar[str] = "audio_recordings" id: int | None = Field(default=None, primary_key=True) file_path: str = Field(max_length=500) # 音频文件路径 file_size: int # 文件大小(字节) duration: float # 录音时长(秒) start_time: datetime = Field(default_factory=get_utc_time) # 开始时间 end_time: datetime | None = None # 结束时间 status: str = Field(default="recording", max_length=20) # 状态:recording, completed, failed is_24x7: bool = False # 是否为7x24小时录制 is_transcribed: bool = False # 是否已完成转录 is_extracted: bool = False # 是否已完成待办/日程提取 is_summarized: bool = False # 是否已完成摘要 is_full_audio: bool = False # 是否为完整音频 is_segment_audio: bool = False # 是否为分段音频(用于句子级回放/定位) transcription_status: str = Field( default="pending", max_length=20 ) # 转录状态:pending, processing, completed, failed def __repr__(self): return f"" class Transcription(TimestampMixin, table=True): """转录文本模型""" __tablename__: ClassVar[str] = "transcriptions" id: int | None = Field(default=None, primary_key=True) audio_recording_id: int # 关联音频录制ID original_text: str | None = Field(default=None, sa_column=Column(Text)) # 原始转录文本 optimized_text: str | None = Field(default=None, sa_column=Column(Text)) # 优化后的文本 extraction_status: str = Field( default="pending", max_length=20 ) # 提取状态:pending, processing, completed, failed extracted_todos: str | None = Field( default=None, sa_column=Column(Text) ) # 从原文提取的待办事项(JSON格式) extracted_schedules: str | None = Field( default=None, sa_column=Column(Text) ) # 从原文提取的日程安排(JSON格式) extracted_todos_optimized: str | None = Field( default=None, sa_column=Column(Text) ) # 从优化文本提取的待办事项(JSON格式) extracted_schedules_optimized: str | None = Field( default=None, sa_column=Column(Text) ) # 从优化文本提取的日程安排(JSON格式) segment_timestamps: str | None = Field( default=None, sa_column=Column(Text) ) # 每段文本的精确时间戳(JSON格式,单位:秒,相对于录音开始时间) def __repr__(self): return f"" # 为兼容旧代码,保留 Base 引用(指向 SQLModel.metadata) # 这样现有的 Base.metadata.create_all() 调用仍然有效 Base = SQLModel ================================================ FILE: lifetrace/storage/notification_storage.py ================================================ """通知存储模块 - 使用内存存储通知,支持去重""" from datetime import datetime from typing import Any from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import naive_as_utc logger = get_logger() # 内存存储:使用字典存储通知,key 为唯一标识符 _notifications: dict[str, dict[str, Any]] = {} # 已取消通知跟踪:记录用户已取消的提醒(todo_id -> reminder_at set) _dismissed_notifications: dict[int, set[str]] = {} def _parse_iso_datetime(value: str | None) -> datetime | None: if not value: return None try: parsed = datetime.fromisoformat(value) except (TypeError, ValueError): return None return naive_as_utc(parsed) def _build_reminder_key(reminder_at: datetime) -> str: return naive_as_utc(reminder_at).isoformat() def add_notification( # noqa: PLR0913 notification_id: str, title: str, content: str, timestamp: datetime, todo_id: int | None = None, schedule_time: datetime | None = None, deadline: datetime | None = None, reminder_at: datetime | None = None, reminder_offset: int | None = None, ) -> bool: """ 添加通知到存储 Args: notification_id: 通知唯一标识符(用于去重) title: 通知标题 content: 通知内容 timestamp: 通知时间戳 todo_id: 关联的待办 ID(可选) schedule_time: 待办时间点(可选,用于检测更新时间) deadline: 待办截止时间(旧字段,兼容旧调用) reminder_at: 提醒触发时间(可选,用于去重和取消) reminder_offset: 提醒偏移分钟数(可选) Returns: bool: 如果通知已存在(去重),返回 False;否则返回 True """ if notification_id in _notifications: logger.debug(f"通知已存在,跳过: {notification_id}") return False notification: dict[str, Any] = { "id": notification_id, "title": title, "content": content, "timestamp": timestamp.isoformat(), } if todo_id is not None: notification["todo_id"] = todo_id effective_time = schedule_time or deadline if effective_time is not None: notification["schedule_time"] = effective_time.isoformat() if deadline is not None: notification["deadline"] = deadline.isoformat() if reminder_at is not None: notification["reminder_at"] = reminder_at.isoformat() if reminder_offset is not None: notification["reminder_offset"] = reminder_offset _notifications[notification_id] = notification logger.info(f"添加通知: {notification_id} - {title}") return True def get_latest_notification() -> dict[str, Any] | None: """ 获取最新的通知 Returns: 最新通知的字典,如果没有通知则返回 None """ notifications = get_notifications() return notifications[0] if notifications else None def get_notifications() -> list[dict[str, Any]]: """获取所有通知(按时间倒序)""" if not _notifications: return [] return sorted( _notifications.values(), key=lambda x: x.get("timestamp", ""), reverse=True, ) def get_notification(notification_id: str) -> dict[str, Any] | None: """ 根据 ID 获取通知 Args: notification_id: 通知 ID Returns: 通知字典,如果不存在则返回 None """ return _notifications.get(notification_id) def clear_notification(notification_id: str) -> bool: """ 清除指定通知(并标记为已取消,防止重复提醒) Args: notification_id: 通知 ID Returns: 如果通知存在并已清除,返回 True;否则返回 False """ if notification_id in _notifications: notification = _notifications[notification_id] todo_id = notification.get("todo_id") reminder_at = _parse_iso_datetime( notification.get("reminder_at") or notification.get("schedule_time") or notification.get("deadline") ) if todo_id is not None and reminder_at is not None: key = _build_reminder_key(reminder_at) existing = _dismissed_notifications.get(todo_id) if existing is None: existing = set() _dismissed_notifications[todo_id] = existing existing.add(key) logger.debug( "标记通知为已取消: todo_id=%s, reminder_at=%s", todo_id, reminder_at.isoformat(), ) del _notifications[notification_id] logger.debug(f"清除通知: {notification_id}") return True return False def clear_all_notifications() -> int: """ 清除所有通知 Returns: 清除的通知数量 """ count = len(_notifications) _notifications.clear() logger.info(f"清除所有通知,共 {count} 条") return count def get_notification_count() -> int: """ 获取当前存储的通知数量 Returns: 通知数量 """ return len(_notifications) def get_notifications_by_todo_id(todo_id: int) -> list[dict[str, Any]]: """根据待办ID查找所有通知""" return [n for n in _notifications.values() if n.get("todo_id") == todo_id] def get_notification_by_todo_id(todo_id: int) -> dict[str, Any] | None: """根据待办ID查找单条通知(兼容旧逻辑)""" notifications = get_notifications_by_todo_id(todo_id) return notifications[0] if notifications else None def clear_notification_by_todo_id(todo_id: int) -> int: """根据待办ID清除所有通知""" notifications = get_notifications_by_todo_id(todo_id) removed = 0 for notification in notifications: notification_id = notification.get("id") if notification_id and clear_notification(notification_id): removed += 1 return removed def is_notification_dismissed(todo_id: int, reminder_at: datetime) -> bool: """检查指定待办的提醒时间是否已被取消""" dismissed = _dismissed_notifications.get(todo_id) if not dismissed: return False return _build_reminder_key(reminder_at) in dismissed def clear_dismissed_mark(todo_id: int) -> None: """ 清除指定待办的已取消标记(用于时间更新时) Args: todo_id: 待办ID """ if todo_id in _dismissed_notifications: del _dismissed_notifications[todo_id] logger.debug(f"清除已取消标记: todo_id={todo_id}") ================================================ FILE: lifetrace/storage/ocr_manager.py ================================================ """OCR管理器 - 负责OCR结果相关的数据库操作""" import hashlib from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import OCRResult, Screenshot from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() def _normalize_text(text: str | None) -> str: """标准化 OCR 文本,用于稳定哈希计算。""" if not text: return "" return " ".join(text.strip().split()) class OCRManager: """OCR结果管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base def add_ocr_result( self, screenshot_id: int, text_content: str, confidence: float = 0.0, language: str = "ch", processing_time: float = 0.0, ) -> int | None: """添加OCR结果""" try: normalized = _normalize_text(text_content) text_hash = ( hashlib.md5(normalized.encode("utf-8"), usedforsecurity=False).hexdigest() if normalized else None ) with self.db_base.get_session() as session: ocr_result = OCRResult( screenshot_id=screenshot_id, text_content=text_content, confidence=confidence, language=language, processing_time=processing_time, text_hash=text_hash, ) session.add(ocr_result) session.flush() # 更新截图处理状态 screenshot = session.query(Screenshot).filter_by(id=screenshot_id).first() if screenshot: screenshot.is_processed = True screenshot.processed_at = get_utc_now() logger.debug(f"添加OCR结果: {ocr_result.id}, text_hash={text_hash}") return ocr_result.id except SQLAlchemyError as e: logger.error(f"添加OCR结果失败: {e}") return None def get_ocr_results_by_screenshot(self, screenshot_id: int) -> list[dict[str, Any]]: """根据截图ID获取OCR结果""" try: with self.db_base.get_session() as session: ocr_results = session.query(OCRResult).filter_by(screenshot_id=screenshot_id).all() # 转换为字典列表 results = [] for ocr in ocr_results: results.append( { "id": ocr.id, "screenshot_id": ocr.screenshot_id, "text_content": ocr.text_content, "confidence": ocr.confidence, "language": ocr.language, "processing_time": ocr.processing_time, "created_at": ocr.created_at, "text_hash": ocr.text_hash, } ) return results except SQLAlchemyError as e: logger.error(f"获取OCR结果失败: {e}") return [] def get_ocr_by_id(self, ocr_result_id: int) -> dict[str, Any] | None: """根据 OCR 结果 ID 获取单条记录。""" try: with self.db_base.get_session() as session: ocr = session.query(OCRResult).filter_by(id=ocr_result_id).first() if not ocr: return None return { "id": ocr.id, "screenshot_id": ocr.screenshot_id, "text_content": ocr.text_content, "confidence": ocr.confidence, "language": ocr.language, "processing_time": ocr.processing_time, "created_at": ocr.created_at, "text_hash": ocr.text_hash, } except SQLAlchemyError as e: logger.error(f"根据ID获取OCR结果失败: {e}") return None def get_by_text_hash(self, text_hash: str) -> dict[str, Any] | None: """根据文本哈希获取一条 OCR 结果,用于判断是否已处理过相同文本。""" if not text_hash: return None try: with self.db_base.get_session() as session: ocr = session.query(OCRResult).filter_by(text_hash=text_hash).first() if not ocr: return None return { "id": ocr.id, "screenshot_id": ocr.screenshot_id, "text_content": ocr.text_content, "confidence": ocr.confidence, "language": ocr.language, "processing_time": ocr.processing_time, "created_at": ocr.created_at, "text_hash": ocr.text_hash, } except SQLAlchemyError as e: logger.error(f"根据文本哈希获取OCR结果失败: {e}") return None ================================================ FILE: lifetrace/storage/screenshot_manager.py ================================================ """截图管理器 - 负责截图相关的数据库操作""" import os from datetime import datetime from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now logger = get_logger() class ScreenshotManager: """截图管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base def add_screenshot( self, file_path: str, file_hash: str, width: int, height: int, metadata: dict[str, Any] | None = None, ) -> int | None: """添加截图记录 Args: file_path: 截图文件路径 file_hash: 文件哈希值 width: 图像宽度 height: 图像高度 metadata: 元数据字典,可包含以下键: - screen_id: 屏幕ID (默认0) - app_name: 应用名称 - window_title: 窗口标题 - event_id: 事件ID """ if metadata is None: metadata = {} screen_id = metadata.get("screen_id", 0) app_name = metadata.get("app_name") window_title = metadata.get("window_title") event_id = metadata.get("event_id") try: with self.db_base.get_session() as session: # 首先检查是否已存在相同路径的截图 existing_path = session.query(Screenshot).filter_by(file_path=file_path).first() if existing_path: logger.debug(f"跳过重复路径截图: {file_path}") return existing_path.id # 检查是否已存在相同哈希的截图 existing_hash = session.query(Screenshot).filter_by(file_hash=file_hash).first() if existing_hash and settings.get("jobs.recorder.params.deduplicate"): logger.debug(f"跳过重复哈希截图: {file_path}") return existing_hash.id file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 screenshot = Screenshot( file_path=file_path, file_hash=file_hash, file_size=file_size, width=width, height=height, screen_id=screen_id, app_name=app_name, window_title=window_title, event_id=event_id, ) session.add(screenshot) session.flush() # 获取ID logger.debug(f"添加截图记录: {screenshot.id}") return screenshot.id except SQLAlchemyError as e: logger.error(f"添加截图记录失败: {e}") return None def get_screenshot_by_id(self, screenshot_id: int) -> dict | None: """根据ID获取截图""" try: with self.db_base.get_session() as session: screenshot = session.query(Screenshot).filter_by(id=screenshot_id).first() if screenshot: # 转换为字典避免会话分离问题 return { "id": screenshot.id, "file_path": screenshot.file_path, "file_hash": screenshot.file_hash, "file_size": screenshot.file_size, "width": screenshot.width, "height": screenshot.height, "screen_id": screenshot.screen_id, "app_name": screenshot.app_name, "window_title": screenshot.window_title, "created_at": screenshot.created_at, "processed_at": screenshot.processed_at, "is_processed": screenshot.is_processed, "file_deleted": screenshot.file_deleted or False, } return None except SQLAlchemyError as e: logger.error(f"获取截图失败: {e}") return None def get_screenshot_by_path(self, file_path: str) -> dict | None: """根据文件路径获取截图""" try: with self.db_base.get_session() as session: screenshot = session.query(Screenshot).filter_by(file_path=file_path).first() if screenshot: # 转换为字典避免会话分离问题 return { "id": screenshot.id, "file_path": screenshot.file_path, "file_hash": screenshot.file_hash, "file_size": screenshot.file_size, "width": screenshot.width, "height": screenshot.height, "screen_id": screenshot.screen_id, "app_name": screenshot.app_name, "window_title": screenshot.window_title, "created_at": screenshot.created_at, "processed_at": screenshot.processed_at, "is_processed": screenshot.is_processed, } return None except SQLAlchemyError as e: logger.error(f"根据路径获取截图失败: {e}") return None def update_screenshot_processed(self, screenshot_id: int): """更新截图处理状态""" try: with self.db_base.get_session() as session: screenshot = session.query(Screenshot).filter_by(id=screenshot_id).first() if screenshot: screenshot.is_processed = True screenshot.processed_at = get_utc_now() logger.debug(f"更新截图处理状态: {screenshot_id}") else: logger.warning(f"未找到截图记录: {screenshot_id}") except SQLAlchemyError as e: logger.error(f"更新截图处理状态失败: {e}") def get_screenshot_count(self, exclude_deleted: bool = False) -> int: """获取截图总数 Args: exclude_deleted: 是否排除已删除文件的记录 Returns: 截图总数 """ try: with self.db_base.get_session() as session: query = session.query(Screenshot) if exclude_deleted: # 排除 file_deleted=True 的记录(包括 None 和 False) query = query.filter(col(Screenshot.file_deleted).is_not(True)) count = query.count() return count except SQLAlchemyError as e: logger.error(f"获取截图总数失败: {e}") return 0 def search_screenshots( self, query: str | None = None, start_date: datetime | None = None, end_date: datetime | None = None, app_name: str | None = None, limit: int = 50, offset: int = 0, ) -> list[dict[str, Any]]: """搜索截图""" try: with self.db_base.get_session() as session: # 基础查询 query_obj = session.query(Screenshot, col(OCRResult.text_content)).outerjoin( OCRResult, col(Screenshot.id) == col(OCRResult.screenshot_id) ) # 添加条件 if start_date: query_obj = query_obj.filter(col(Screenshot.created_at) >= start_date) if end_date: query_obj = query_obj.filter(col(Screenshot.created_at) <= end_date) if app_name: query_obj = query_obj.filter(col(Screenshot.app_name).like(f"%{app_name}%")) if query: query_obj = query_obj.filter(col(OCRResult.text_content).like(f"%{query}%")) # 应用分页:先排序,再应用offset和limit results = ( query_obj.order_by(col(Screenshot.created_at).desc()) .offset(offset) .limit(limit) .all() ) # 格式化结果 formatted_results = [] for screenshot, text_content in results: formatted_results.append( { "id": screenshot.id, "file_path": screenshot.file_path, "app_name": screenshot.app_name, "window_title": screenshot.window_title, "created_at": screenshot.created_at, "text_content": text_content, "width": screenshot.width, "height": screenshot.height, "file_deleted": screenshot.file_deleted or False, } ) return formatted_results except SQLAlchemyError as e: logger.error(f"搜索截图失败: {e}") return [] def get_unprocessed_screenshots(self, limit: int = 100) -> list[dict[str, Any]]: """获取未分配事件的截图列表(按时间升序) Args: limit: 最多返回的截图数量 Returns: 截图列表 """ try: with self.db_base.get_session() as session: screenshots = ( session.query(Screenshot) .filter(col(Screenshot.event_id).is_(None)) .order_by(col(Screenshot.created_at).asc()) .limit(limit) .all() ) return [ { "id": s.id, "file_path": s.file_path, "app_name": s.app_name, "window_title": s.window_title, "created_at": s.created_at, } for s in screenshots ] except SQLAlchemyError as e: logger.error(f"获取未处理截图失败: {e}") return [] ================================================ FILE: lifetrace/storage/sql_utils.py ================================================ """SQLAlchemy typing helpers for SQLModel query expressions.""" from typing import Any, TypeVar, cast from sqlalchemy.sql.elements import ColumnElement T = TypeVar("T") def col(expr: Any) -> ColumnElement[Any]: """Cast SQLModel attributes to SQLAlchemy column elements for type checking.""" return cast("ColumnElement[Any]", expr) ================================================ FILE: lifetrace/storage/stats_manager.py ================================================ """统计管理器 - 负责统计信息和数据清理相关的数据库操作""" import os from datetime import timedelta from typing import Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.database_base import DatabaseBase from lifetrace.storage.models import OCRResult, Screenshot from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() class StatsManager: """统计和数据清理管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base def get_statistics(self) -> dict[str, Any]: """获取统计信息""" try: with self.db_base.get_session() as session: total_screenshots = session.query(Screenshot).count() processed_screenshots = ( session.query(Screenshot).filter_by(is_processed=True).count() ) # 今日统计 now = get_utc_now() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) today_screenshots = ( session.query(Screenshot) .filter(col(Screenshot.created_at) >= today_start) .count() ) return { "total_screenshots": total_screenshots, "processed_screenshots": processed_screenshots, "today_screenshots": today_screenshots, "processing_rate": processed_screenshots / max(total_screenshots, 1) * 100, } except SQLAlchemyError as e: logger.error(f"获取统计信息失败: {e}") return {} def cleanup_old_data(self, max_days: int): """清理旧数据""" if max_days <= 0: return try: cutoff_date = get_utc_now() - timedelta(days=max_days) with self.db_base.get_session() as session: # 获取要删除的截图 old_screenshots = ( session.query(Screenshot).filter(col(Screenshot.created_at) < cutoff_date).all() ) deleted_count = 0 for screenshot in old_screenshots: # 删除相关的OCR结果 session.query(OCRResult).filter_by(screenshot_id=screenshot.id).delete() # 删除文件 if os.path.exists(screenshot.file_path): try: os.remove(screenshot.file_path) except Exception as e: logger.error(f"删除文件失败 {screenshot.file_path}: {e}") # 删除截图记录 session.delete(screenshot) deleted_count += 1 logger.info(f"清理了 {deleted_count} 条旧数据") except SQLAlchemyError as e: logger.error(f"清理旧数据失败: {e}") ================================================ FILE: lifetrace/storage/todo_manager.py ================================================ """Todo 管理器 - 负责 Todo/Tag/Attachment 相关数据库操作""" from __future__ import annotations import contextlib from typing import TYPE_CHECKING, Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.models import Tag, Todo, TodoAttachmentRelation, TodoTagRelation from lifetrace.storage.sql_utils import col from lifetrace.storage.todo_manager_attachments import TodoAttachmentMixin from lifetrace.storage.todo_manager_ical import TodoIcalMixin from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() if TYPE_CHECKING: from lifetrace.storage.database_base import DatabaseBase class TodoManager(TodoAttachmentMixin, TodoIcalMixin): """Todo 管理类""" def __init__(self, db_base: DatabaseBase): self.db_base = db_base # ========== 查询辅助 ========== def _get_todo_tags(self, session, todo_id: int) -> list[str]: rows = ( session.query(col(Tag.tag_name)) .join(TodoTagRelation, col(TodoTagRelation.tag_id) == col(Tag.id)) .filter(col(TodoTagRelation.todo_id) == todo_id) .all() ) return [r[0] for r in rows if r and r[0]] def get_todo_context(self, todo_id: int) -> dict[str, Any] | None: """获取任务的所有相关上下文(父任务链、同级任务、子任务)""" try: with self.db_base.get_session() as session: # 获取当前任务 current_todo = session.query(Todo).filter_by(id=todo_id).first() if not current_todo: return None current_dict = self._todo_to_dict(session, current_todo) # 递归向上查找所有父任务 parents: list[dict[str, Any]] = [] parent_id = current_todo.parent_todo_id visited_parents = set() # 防止循环引用 while parent_id is not None and parent_id not in visited_parents: visited_parents.add(parent_id) parent_todo = session.query(Todo).filter_by(id=parent_id).first() if not parent_todo: break parents.append(self._todo_to_dict(session, parent_todo)) parent_id = parent_todo.parent_todo_id # 查找所有同级任务(相同 parent_todo_id,排除当前任务) siblings: list[dict[str, Any]] = [] if current_todo.parent_todo_id is not None: sibling_todos = ( session.query(Todo) .filter( col(Todo.parent_todo_id) == current_todo.parent_todo_id, col(Todo.id) != todo_id, ) .all() ) siblings = [self._todo_to_dict(session, t) for t in sibling_todos] # 递归向下查找所有子任务 def _get_children_recursive(parent_todo_id: int) -> list[dict[str, Any]]: children: list[dict[str, Any]] = [] child_todos = ( session.query(Todo).filter(col(Todo.parent_todo_id) == parent_todo_id).all() ) for child in child_todos: child_dict = self._todo_to_dict(session, child) # 递归获取子任务的子任务 child_dict["children"] = _get_children_recursive(child.id) children.append(child_dict) return children children = _get_children_recursive(todo_id) return { "current": current_dict, "parents": parents, "siblings": siblings, "children": children, } except SQLAlchemyError as e: logger.error(f"获取 todo 上下文失败: {e}") return None # ========== CRUD ========== def get_todo(self, todo_id: int) -> dict[str, Any] | None: try: with self.db_base.get_session() as session: todo = session.query(Todo).filter_by(id=todo_id).first() if not todo: return None return self._todo_to_dict(session, todo) except SQLAlchemyError as e: logger.error(f"获取 todo 失败: {e}") return None def get_todo_by_uid(self, uid: str) -> dict[str, Any] | None: if not uid: return None try: with self.db_base.get_session() as session: todo = session.query(Todo).filter_by(uid=uid).first() if not todo: return None return self._todo_to_dict(session, todo) except SQLAlchemyError as e: logger.error(f"根据 uid 获取 todo 失败: {e}") return None def list_todos( self, *, limit: int = 200, offset: int = 0, status: str | None = None, ) -> list[dict[str, Any]]: try: with self.db_base.get_session() as session: q = session.query(Todo) # 默认不返回软删除数据(如果未来使用 deleted_at) with contextlib.suppress(Exception): q = q.filter(col(Todo.deleted_at).is_(None)) if status: q = q.filter(col(Todo.status) == status) todos = q.order_by(col(Todo.created_at).desc()).offset(offset).limit(limit).all() return [self._todo_to_dict(session, t) for t in todos] except SQLAlchemyError as e: logger.error(f"列出 todo 失败: {e}") return [] def count_todos(self, *, status: str | None = None) -> int: try: with self.db_base.get_session() as session: q = session.query(Todo) with contextlib.suppress(Exception): q = q.filter(col(Todo.deleted_at).is_(None)) if status: q = q.filter(col(Todo.status) == status) return q.count() except SQLAlchemyError as e: logger.error(f"统计 todo 数量失败: {e}") return 0 def get_active_todos_for_prompt(self, limit: int = 100) -> list[dict[str, Any]]: """获取用于提示词的活跃 todo 列表(精简字段)。 返回的数据适合直接序列化为 JSON 传给 LLM,让模型了解当前已有的待办。 """ try: with self.db_base.get_session() as session: q = session.query(Todo) with contextlib.suppress(Exception): q = q.filter(col(Todo.deleted_at).is_(None)) q = ( q.filter(col(Todo.status) == "active") .order_by(col(Todo.created_at).desc()) .limit(limit) ) todos = q.all() result: list[dict[str, Any]] = [] for t in todos: schedule = t.dtstart or t.start_time or t.due or t.deadline result.append( { "id": t.id, "name": t.name, "description": t.description, "start_time": schedule.isoformat() if schedule else None, } ) return result except SQLAlchemyError as e: logger.error(f"获取用于提示词的活跃 todo 列表失败: {e}") return [] def _delete_todo_recursive(self, session, todo_id: int) -> None: """递归删除 todo 及其所有子任务""" # 查找所有子任务 child_todos = session.query(Todo).filter(col(Todo.parent_todo_id) == todo_id).all() # 递归删除所有子任务 for child in child_todos: self._delete_todo_recursive(session, child.id) # 清理关联关系(不删除 Tag/Attachment 实体) session.query(TodoTagRelation).filter(col(TodoTagRelation.todo_id) == todo_id).delete() session.query(TodoAttachmentRelation).filter( col(TodoAttachmentRelation.todo_id) == todo_id ).delete() # 删除 todo 本身 todo = session.query(Todo).filter_by(id=todo_id).first() if todo: session.delete(todo) logger.info(f"删除 todo: {todo_id}") def delete_todo(self, todo_id: int) -> bool: try: with self.db_base.get_session() as session: todo = session.query(Todo).filter_by(id=todo_id).first() if not todo: logger.warning(f"todo 不存在: {todo_id}") return False # 递归删除 todo 及其所有子任务 self._delete_todo_recursive(session, todo_id) session.flush() logger.info(f"删除 todo 及其子任务: {todo_id}") return True except SQLAlchemyError as e: logger.error(f"删除 todo 失败: {e}") return False # ========== 关系写入 ========== def reorder_todos(self, items: list[dict[str, Any]]) -> bool: """批量更新待办的排序和父子关系 Args: items: 待办列表,每个元素包含 id, order, 可选 parent_todo_id Returns: 是否全部更新成功 """ try: with self.db_base.get_session() as session: for item in items: todo_id = item.get("id") if not todo_id: continue todo = session.query(Todo).filter_by(id=todo_id).first() if not todo: logger.warning(f"reorder_todos: todo 不存在: {todo_id}") continue # 更新 order if "order" in item: todo.order = item["order"] # 更新 parent_todo_id(如果提供了该字段) if "parent_todo_id" in item: todo.parent_todo_id = item["parent_todo_id"] todo.updated_at = get_utc_now() session.flush() logger.info(f"批量重排序 {len(items)} 个待办") return True except SQLAlchemyError as e: logger.error(f"批量重排序待办失败: {e}") return False def _set_todo_tags(self, session, todo_id: int, tags: list[str]) -> None: # 清空旧关系 session.query(TodoTagRelation).filter(col(TodoTagRelation.todo_id) == todo_id).delete() # 去重/清洗 cleaned = [] seen = set() for t in tags: name = (t or "").strip() if not name: continue if name in seen: continue seen.add(name) cleaned.append(name) for tag_name in cleaned: tag = session.query(Tag).filter_by(tag_name=tag_name).first() if not tag: tag = Tag(tag_name=tag_name) session.add(tag) session.flush() if tag.id is None: raise ValueError("Tag must have an id before creating relation.") rel = TodoTagRelation(todo_id=todo_id, tag_id=tag.id) session.add(rel) ================================================ FILE: lifetrace/storage/todo_manager_attachments.py ================================================ """Attachment helpers for TodoManager.""" from __future__ import annotations from typing import TYPE_CHECKING, Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.models import Attachment, Todo, TodoAttachmentRelation from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger logger = get_logger() if TYPE_CHECKING: from lifetrace.storage.database_base import DatabaseBase class TodoAttachmentMixin: """Attachment-related helpers for TodoManager.""" db_base: DatabaseBase def _get_todo_attachments(self, session, todo_id: int) -> list[dict[str, Any]]: rows = ( session.query(Attachment, TodoAttachmentRelation) .join( TodoAttachmentRelation, col(TodoAttachmentRelation.attachment_id) == col(Attachment.id), ) .filter( col(TodoAttachmentRelation.todo_id) == todo_id, col(TodoAttachmentRelation.deleted_at).is_(None), ) .all() ) return [ { "id": attachment.id, "file_name": attachment.file_name, "file_path": attachment.file_path, "file_size": attachment.file_size, "mime_type": attachment.mime_type, "source": relation.source, } for attachment, relation in rows ] def add_todo_attachment( self, *, todo_id: int, file_name: str, file_path: str, file_size: int | None, mime_type: str | None, file_hash: str | None, source: str = "user", ) -> dict[str, Any] | None: try: with self.db_base.get_session() as session: todo = session.query(Todo).filter_by(id=todo_id).first() if not todo: return None attachment = Attachment( file_name=file_name, file_path=file_path, file_size=file_size, mime_type=mime_type, file_hash=file_hash, ) session.add(attachment) session.flush() if attachment.id is None: raise ValueError("Attachment must have an id before linking.") relation = TodoAttachmentRelation( todo_id=todo_id, attachment_id=attachment.id, source=source or "user", ) session.add(relation) session.flush() return { "id": attachment.id, "file_name": attachment.file_name, "file_path": attachment.file_path, "file_size": attachment.file_size, "mime_type": attachment.mime_type, "source": relation.source, } except SQLAlchemyError as exc: logger.error(f"Failed to create attachment: {exc}") return None def remove_todo_attachment(self, *, todo_id: int, attachment_id: int) -> bool: try: with self.db_base.get_session() as session: rows = ( session.query(TodoAttachmentRelation) .filter( col(TodoAttachmentRelation.todo_id) == todo_id, col(TodoAttachmentRelation.attachment_id) == attachment_id, ) .delete() ) return rows > 0 except SQLAlchemyError as exc: logger.error(f"Failed to unlink attachment: {exc}") return False def get_attachment(self, attachment_id: int) -> dict[str, Any] | None: try: with self.db_base.get_session() as session: attachment = session.query(Attachment).filter_by(id=attachment_id).first() if not attachment: return None return { "id": attachment.id, "file_name": attachment.file_name, "file_path": attachment.file_path, "file_size": attachment.file_size, "mime_type": attachment.mime_type, } except SQLAlchemyError as exc: logger.error(f"Failed to fetch attachment: {exc}") return None ================================================ FILE: lifetrace/storage/todo_manager_ical.py ================================================ """Todo manager iCalendar mappings and CRUD helpers.""" from __future__ import annotations import json from typing import TYPE_CHECKING, Any from sqlalchemy.exc import SQLAlchemyError from lifetrace.storage.models import Todo from lifetrace.storage.todo_manager_utils import ( _normalize_percent, _normalize_reminder_offsets, _safe_int_list, _serialize_reminder_offsets, ) from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() _UNSET = object() if TYPE_CHECKING: from datetime import datetime from lifetrace.storage.database_base import DatabaseBase def _to_ical_status(status: str | None) -> str | None: if not status: return None mapping = { "active": "NEEDS-ACTION", "completed": "COMPLETED", "canceled": "CANCELLED", "draft": "NEEDS-ACTION", } return mapping.get(status, "NEEDS-ACTION") class TodoIcalMixin: """Mixin for iCalendar-aware Todo CRUD and serialization.""" if TYPE_CHECKING: db_base: DatabaseBase def _get_todo_tags(self, session, todo_id: int) -> list[str]: ... def _get_todo_attachments(self, session, todo_id: int) -> list[dict[str, Any]]: ... def _set_todo_tags(self, session, todo_id: int, tags: list[str]) -> None: ... def _todo_to_dict(self, session, todo: Todo) -> dict[str, Any]: todo_id = todo.id if todo_id is None: raise ValueError("Todo must have an id before serialization.") summary = getattr(todo, "summary", None) or todo.name dtstart = getattr(todo, "dtstart", None) or todo.start_time dtend = getattr(todo, "dtend", None) or todo.end_time due = getattr(todo, "due", None) or todo.deadline tzid = getattr(todo, "tzid", None) or getattr(todo, "time_zone", None) created = getattr(todo, "created", None) or todo.created_at last_modified = getattr(todo, "last_modified", None) or todo.updated_at dtstamp = getattr(todo, "dtstamp", None) or todo.updated_at ical_status = getattr(todo, "ical_status", None) or _to_ical_status(todo.status) is_all_day = getattr(todo, "is_all_day", None) if is_all_day is None: is_all_day = False return { "id": todo_id, "uid": getattr(todo, "uid", None), "name": todo.name, "summary": summary, "description": todo.description, "user_notes": todo.user_notes, "parent_todo_id": todo.parent_todo_id, "item_type": getattr(todo, "item_type", None), "location": getattr(todo, "location", None), "categories": getattr(todo, "categories", None), "classification": getattr(todo, "classification", None), "deadline": todo.deadline, "start_time": todo.start_time, "end_time": todo.end_time, "dtstart": dtstart, "dtend": dtend, "due": due, "duration": getattr(todo, "duration", None), "time_zone": getattr(todo, "time_zone", None), "tzid": tzid, "is_all_day": bool(is_all_day), "dtstamp": dtstamp, "created": created, "last_modified": last_modified, "sequence": getattr(todo, "sequence", 0), "rdate": getattr(todo, "rdate", None), "exdate": getattr(todo, "exdate", None), "recurrence_id": getattr(todo, "recurrence_id", None), "related_to_uid": getattr(todo, "related_to_uid", None), "related_to_reltype": getattr(todo, "related_to_reltype", None), "ical_status": ical_status, "reminder_offsets": _normalize_reminder_offsets( getattr(todo, "reminder_offsets", None) ), "status": todo.status, "priority": todo.priority, "completed_at": getattr(todo, "completed_at", None), "percent_complete": ( todo.percent_complete if getattr(todo, "percent_complete", None) is not None else 0 ), "rrule": getattr(todo, "rrule", None), "order": getattr(todo, "order", 0), "tags": self._get_todo_tags(session, todo_id), "attachments": self._get_todo_attachments(session, todo_id), "related_activities": _safe_int_list(todo.related_activities), "source_type": getattr(todo, "source_type", None), "source_key": getattr(todo, "source_key", None), "source_date": getattr(todo, "source_date", None), "created_at": todo.created_at, "updated_at": todo.updated_at, } def create_todo( # noqa: PLR0913, C901, PLR0912 self, *, name: str, summary: str | None = None, description: str | None = None, user_notes: str | None = None, parent_todo_id: int | None = None, item_type: str | None = None, location: str | None = None, categories: str | None = None, classification: str | None = None, deadline: datetime | None = None, start_time: datetime | None = None, end_time: datetime | None = None, dtstart: datetime | None = None, dtend: datetime | None = None, due: datetime | None = None, duration: str | None = None, time_zone: str | None = None, tzid: str | None = None, is_all_day: bool | None = None, dtstamp: datetime | None = None, created: datetime | None = None, last_modified: datetime | None = None, sequence: int | None = None, rdate: str | None = None, exdate: str | None = None, recurrence_id: datetime | None = None, related_to_uid: str | None = None, related_to_reltype: str | None = None, ical_status: str | None = None, reminder_offsets: list[int] | None = None, status: str = "active", priority: str = "none", completed_at: datetime | None = None, percent_complete: int | None = None, rrule: str | None = None, uid: str | None = None, order: int = 0, tags: list[str] | None = None, related_activities: list[int] | None = None, ) -> int | None: try: resolved_percent = ( _normalize_percent(percent_complete) if percent_complete is not None else None ) if resolved_percent is None: resolved_percent = 100 if status == "completed" else 0 resolved_completed_at = completed_at if resolved_completed_at is None and status == "completed": resolved_completed_at = get_utc_now() cleaned_rrule = (rrule or "").strip() or None cleaned_uid = (uid or "").strip() or None with self.db_base.get_session() as session: if dtstart is None: dtstart = start_time or deadline or due if due is None: due = deadline if dtend is None: dtend = end_time if start_time is None and dtstart is not None: start_time = dtstart if end_time is None and dtend is not None: end_time = dtend if deadline is None and due is not None: deadline = due resolved_summary = summary or name resolved_item_type = (item_type or "VTODO").upper() resolved_tzid = tzid or time_zone now = get_utc_now() if created is None: created = now if last_modified is None: last_modified = now if dtstamp is None: dtstamp = now todo_kwargs: dict[str, Any] = { "name": name, "summary": resolved_summary, "description": description, "user_notes": user_notes, "parent_todo_id": parent_todo_id, "item_type": resolved_item_type, "location": location, "categories": categories, "classification": classification, "deadline": deadline, "start_time": start_time, "end_time": end_time, "dtstart": dtstart, "dtend": dtend, "due": due, "duration": duration, "time_zone": time_zone, "tzid": resolved_tzid, "is_all_day": bool(is_all_day) if is_all_day is not None else False, "dtstamp": dtstamp, "created": created, "last_modified": last_modified, "sequence": sequence if sequence is not None else 0, "rdate": rdate, "exdate": exdate, "recurrence_id": recurrence_id, "related_to_uid": related_to_uid, "related_to_reltype": related_to_reltype, "ical_status": ical_status, "reminder_offsets": _serialize_reminder_offsets(reminder_offsets), "status": status, "priority": priority, "completed_at": resolved_completed_at, "percent_complete": resolved_percent, "rrule": cleaned_rrule, "order": order, "related_activities": json.dumps(_safe_int_list(related_activities)), } if cleaned_uid: todo_kwargs["uid"] = cleaned_uid todo = Todo(**todo_kwargs) session.add(todo) session.flush() if tags is not None: if todo.id is None: raise ValueError("Todo must have an id before tagging.") self._set_todo_tags(session, todo.id, tags) logger.info(f"创建 todo: {todo.id} - {name}") return todo.id except SQLAlchemyError as e: logger.error(f"创建 todo 失败: {e}") return None def _apply_todo_updates( # noqa: PLR0913 self, todo: Todo, *, name: str | Any = _UNSET, summary: str | Any = _UNSET, description: str | Any = _UNSET, user_notes: str | Any = _UNSET, parent_todo_id: int | None | Any = _UNSET, item_type: str | None | Any = _UNSET, location: str | None | Any = _UNSET, categories: str | None | Any = _UNSET, classification: str | None | Any = _UNSET, deadline: datetime | None | Any = _UNSET, start_time: datetime | None | Any = _UNSET, end_time: datetime | None | Any = _UNSET, dtstart: datetime | None | Any = _UNSET, dtend: datetime | None | Any = _UNSET, due: datetime | None | Any = _UNSET, duration: str | None | Any = _UNSET, time_zone: str | None | Any = _UNSET, tzid: str | None | Any = _UNSET, is_all_day: bool | None | Any = _UNSET, dtstamp: datetime | None | Any = _UNSET, created: datetime | None | Any = _UNSET, last_modified: datetime | None | Any = _UNSET, sequence: int | Any = _UNSET, rdate: str | None | Any = _UNSET, exdate: str | None | Any = _UNSET, recurrence_id: datetime | None | Any = _UNSET, related_to_uid: str | None | Any = _UNSET, related_to_reltype: str | None | Any = _UNSET, ical_status: str | None | Any = _UNSET, reminder_offsets: list[int] | None | Any = _UNSET, status: str | Any = _UNSET, priority: str | Any = _UNSET, completed_at: datetime | None | Any = _UNSET, percent_complete: int | Any = _UNSET, rrule: str | None | Any = _UNSET, order: int | Any = _UNSET, related_activities: list[int] | Any = _UNSET, ) -> None: """应用待办字段更新.""" if percent_complete is not _UNSET: percent_complete = _normalize_percent(percent_complete) if rrule is not _UNSET: rrule = (rrule or "").strip() or None updates = { "name": name, "summary": summary, "description": description, "user_notes": user_notes, "parent_todo_id": parent_todo_id, "item_type": item_type, "location": location, "categories": categories, "classification": classification, "deadline": deadline, "start_time": start_time, "end_time": end_time, "dtstart": dtstart, "dtend": dtend, "due": due, "duration": duration, "time_zone": time_zone, "tzid": tzid, "is_all_day": is_all_day, "dtstamp": dtstamp, "created": created, "last_modified": last_modified, "sequence": sequence, "rdate": rdate, "exdate": exdate, "recurrence_id": recurrence_id, "related_to_uid": related_to_uid, "related_to_reltype": related_to_reltype, "ical_status": ical_status, "status": status, "priority": priority, "completed_at": completed_at, "percent_complete": percent_complete, "rrule": rrule, "order": order, } for attr, value in updates.items(): if value is not _UNSET: setattr(todo, attr, value) if reminder_offsets is not _UNSET: todo.reminder_offsets = _serialize_reminder_offsets(reminder_offsets) if related_activities is not _UNSET: todo.related_activities = json.dumps(_safe_int_list(related_activities)) def update_todo( # noqa: PLR0913, C901, PLR0912, PLR0915 self, todo_id: int, *, name: str | Any = _UNSET, summary: str | Any = _UNSET, description: str | Any = _UNSET, user_notes: str | Any = _UNSET, parent_todo_id: int | None | Any = _UNSET, item_type: str | None | Any = _UNSET, location: str | None | Any = _UNSET, categories: str | None | Any = _UNSET, classification: str | None | Any = _UNSET, deadline: datetime | None | Any = _UNSET, start_time: datetime | None | Any = _UNSET, end_time: datetime | None | Any = _UNSET, dtstart: datetime | None | Any = _UNSET, dtend: datetime | None | Any = _UNSET, due: datetime | None | Any = _UNSET, duration: str | None | Any = _UNSET, time_zone: str | None | Any = _UNSET, tzid: str | None | Any = _UNSET, is_all_day: bool | None | Any = _UNSET, dtstamp: datetime | None | Any = _UNSET, created: datetime | None | Any = _UNSET, last_modified: datetime | None | Any = _UNSET, sequence: int | Any = _UNSET, rdate: str | None | Any = _UNSET, exdate: str | None | Any = _UNSET, recurrence_id: datetime | None | Any = _UNSET, related_to_uid: str | None | Any = _UNSET, related_to_reltype: str | None | Any = _UNSET, ical_status: str | None | Any = _UNSET, reminder_offsets: list[int] | None | Any = _UNSET, status: str | Any = _UNSET, priority: str | Any = _UNSET, completed_at: datetime | None | Any = _UNSET, percent_complete: int | Any = _UNSET, rrule: str | None | Any = _UNSET, order: int | Any = _UNSET, tags: list[str] | Any = _UNSET, related_activities: list[int] | Any = _UNSET, ) -> bool: try: with self.db_base.get_session() as session: todo = session.query(Todo).filter_by(id=todo_id).first() if not todo: logger.warning(f"todo 不存在: {todo_id}") return False resolved_completed_at = completed_at resolved_percent = percent_complete if status is not _UNSET: if status == "completed": if completed_at is _UNSET: resolved_completed_at = get_utc_now() if percent_complete is _UNSET: resolved_percent = 100 else: if completed_at is _UNSET: resolved_completed_at = None if percent_complete is _UNSET: resolved_percent = 0 if item_type is not _UNSET and item_type is not None: item_type = item_type.upper() if summary is _UNSET and name is not _UNSET: summary = name if name is _UNSET and summary is not _UNSET: name = summary if tzid is _UNSET and time_zone is not _UNSET: tzid = time_zone if time_zone is _UNSET and tzid is not _UNSET: time_zone = tzid if start_time is _UNSET and dtstart is not _UNSET: start_time = dtstart if end_time is _UNSET and dtend is not _UNSET: end_time = dtend if deadline is _UNSET and due is not _UNSET: deadline = due if dtstart is _UNSET and start_time is not _UNSET: dtstart = start_time if dtend is _UNSET and end_time is not _UNSET: dtend = end_time if due is _UNSET and deadline is not _UNSET: due = deadline if deadline is not _UNSET and start_time is _UNSET: start_time = deadline deadline = None if last_modified is _UNSET: last_modified = get_utc_now() if dtstamp is _UNSET: dtstamp = last_modified self._apply_todo_updates( todo, name=name, summary=summary, description=description, user_notes=user_notes, parent_todo_id=parent_todo_id, item_type=item_type, location=location, categories=categories, classification=classification, deadline=deadline, start_time=start_time, end_time=end_time, dtstart=dtstart, dtend=dtend, due=due, duration=duration, time_zone=time_zone, tzid=tzid, is_all_day=is_all_day, dtstamp=dtstamp, created=created, last_modified=last_modified, sequence=sequence, rdate=rdate, exdate=exdate, recurrence_id=recurrence_id, related_to_uid=related_to_uid, related_to_reltype=related_to_reltype, ical_status=ical_status, reminder_offsets=reminder_offsets, status=status, priority=priority, completed_at=resolved_completed_at, percent_complete=resolved_percent, rrule=rrule, order=order, related_activities=related_activities, ) todo.updated_at = get_utc_now() session.flush() if tags is not _UNSET: self._set_todo_tags(session, todo_id, tags or []) logger.info(f"更新 todo: {todo_id}") return True except SQLAlchemyError as e: logger.error(f"更新 todo 失败: {e}") return False ================================================ FILE: lifetrace/storage/todo_manager_utils.py ================================================ """Helper utilities for TodoManager.""" from __future__ import annotations import contextlib import json from typing import Any def _safe_int_list(value: Any) -> list[int]: if value is None: return [] if isinstance(value, list): out: list[int] = [] for item in value: with contextlib.suppress(Exception): out.append(int(item)) return out # 兼容数据库中存的 JSON 字符串 if isinstance(value, str): try: parsed = json.loads(value) return _safe_int_list(parsed) except Exception: return [] return [] def _normalize_reminder_offsets(value: Any) -> list[int] | None: if value is None: return None offsets = _safe_int_list(value) cleaned = sorted({offset for offset in offsets if offset >= 0}) return cleaned def _serialize_reminder_offsets(value: Any) -> str | None: normalized = _normalize_reminder_offsets(value) if normalized is None: return None return json.dumps(normalized) def _normalize_percent(value: Any) -> int: if value is None: return 0 try: percent = int(value) except Exception: return 0 return max(0, min(100, percent)) ================================================ FILE: lifetrace/util/app_utils.py ================================================ """ 应用工具模块 合并了应用名称映射和应用图标映射功能 提供跨平台应用名称到进程名的映射,以及应用名称到图标文件的映射 """ import platform # ==================== 应用图标映射 ==================== # 应用名称(小写) -> 图标文件名 # 支持 .exe 文件名、应用显示名、中文名等多种形式 APP_ICON_MAPPING = { # 浏览器 "chrome.exe": "chrome.png", "chrome": "chrome.png", "google chrome": "chrome.png", "msedge.exe": "edge.png", "edge": "edge.png", "edge.exe": "edge.png", "microsoft edge": "edge.png", "firefox.exe": "firefox.png", "firefox": "firefox.png", "mozilla firefox": "firefox.png", # 开发工具 "code.exe": "vscode.png", "code": "vscode.png", "vscode": "vscode.png", "visual studio code": "vscode.png", "pycharm64.exe": "pycharm.png", "pycharm.exe": "pycharm.png", "pycharm": "pycharm.png", "idea64.exe": "intellij.png", "intellij": "intellij.png", "intellij idea": "intellij.png", "webstorm64.exe": "webstorm.png", "webstorm.exe": "webstorm.png", "webstorm": "webstorm.png", "githubdesktop.exe": "github.png", "github desktop": "github.png", "github": "github.png", # 通讯工具 "wechat.exe": "wechat.png", "weixin.exe": "wechat.png", "wechat": "wechat.png", "weixin": "wechat.png", "微信": "wechat.png", "qq.exe": "qq.png", "qq": "qq.png", "telegram.exe": "telegram.png", "telegram": "telegram.png", "discord.exe": "discord.png", "discord": "discord.png", # Office 套件 "winword.exe": "word.png", "word": "word.png", "microsoft word": "word.png", "excel.exe": "excel.png", "excel": "excel.png", "microsoft excel": "excel.png", "powerpnt.exe": "powerpoint.png", "powerpoint.exe": "powerpoint.png", "powerpoint": "powerpoint.png", "microsoft powerpoint": "powerpoint.png", "wps.exe": "wps.png", "wps": "wps.png", "wpp.exe": "powerpoint.png", # WPS演示 "et.exe": "excel.png", # WPS表格 # 设计工具 "photoshop.exe": "photoshop.png", "photoshop": "photoshop.png", "xmind.exe": "xmind.png", "xmind": "xmind.png", "snipaste.exe": "snipaste.png", "snipaste": "snipaste.png", # 媒体工具 "spotify.exe": "spotify.png", "spotify": "spotify.png", "vlc.exe": "vlc.png", "vlc": "vlc.png", # macOS 应用 "finder": "explorer.png", "访达": "explorer.png", "iterm2": "vscode.png", "iterm": "vscode.png", "terminal": "vscode.png", "cursor": "cursor.png", "cursor.exe": "cursor.png", "chatgpt": "chrome.png", "chatgpt atlas": "chrome.png", "chatgpt desktop": "chrome.png", "atlas": "chrome.png", # ChatGPT Atlas 的简称 # 飞书相关 "feishu": "feishu.png", "feishu.exe": "feishu.png", "lark": "feishu.png", "lark.exe": "feishu.png", "飞书": "feishu.png", "飞书会议": "feishu.png", # 系统工具 "explorer.exe": "explorer.png", "explorer": "explorer.png", "file explorer": "explorer.png", "文件资源管理器": "explorer.png", "notepad.exe": "notepad.png", "notepad": "notepad.png", "记事本": "notepad.png", "calc.exe": "calculator.png", "calculator.exe": "calculator.png", "calculator": "calculator.png", "计算器": "calculator.png", } def get_icon_filename(app_name): """ 根据应用名称获取图标文件名 Args: app_name: 应用名称(支持exe文件名、显示名等) Returns: 图标文件名,如果未找到返回None """ if not app_name: return None # 转为小写进行匹配 app_name_lower = app_name.lower().strip() # 精确匹配 if app_name_lower in APP_ICON_MAPPING: return APP_ICON_MAPPING[app_name_lower] # 模糊匹配(部分包含) for key, icon_file in APP_ICON_MAPPING.items(): if key in app_name_lower or app_name_lower in key: return icon_file return None def get_all_supported_apps(): """获取所有支持的应用列表""" return sorted(set(APP_ICON_MAPPING.values())) # ==================== 应用名称映射 ==================== # 跨平台应用名称映射字典 # key: 友好的应用名称(用户配置时使用) # value: 字典,包含各平台的进程名列表 APP_MAPPING: dict[str, dict[str, list[str]]] = { # 即时通讯软件 "微信": { "Windows": ["WeChat.exe", "Weixin.exe", "微信.exe"], "Darwin": ["WeChat", "微信"], # macOS 可能返回中文或英文名 "Linux": ["wechat", "electronic-wechat"], }, "WeChat": { "Windows": ["WeChat.exe", "Weixin.exe", "微信.exe"], "Darwin": ["WeChat", "微信"], # macOS 可能返回中文或英文名 "Linux": ["wechat", "electronic-wechat"], }, "QQ": { "Windows": ["QQ.exe", "QQScLauncher.exe"], "Darwin": ["QQ"], "Linux": ["qq", "linuxqq"], }, "钉钉": { "Windows": ["DingTalk.exe", "钉钉.exe"], "Darwin": ["DingTalk", "钉钉"], # macOS 可能返回中文或英文名 "Linux": ["dingtalk"], }, "企业微信": { "Windows": ["WXWork.exe", "企业微信.exe"], "Darwin": ["企业微信", "WeCom"], # macOS 可能返回中文或英文名 "Linux": ["wxwork"], }, "飞书": { "Windows": ["Feishu.exe", "Lark.exe", "飞书.exe"], "Darwin": ["Feishu", "Lark", "飞书"], # macOS 可能返回中文或英文名 "Linux": ["feishu", "lark"], }, "Telegram": { "Windows": ["Telegram.exe"], "Darwin": ["Telegram"], "Linux": ["telegram-desktop", "telegram"], }, "Discord": { "Windows": ["Discord.exe"], "Darwin": ["Discord"], "Linux": ["discord", "Discord"], }, # 办公软件 "记事本": { "Windows": ["notepad.exe"], "Darwin": ["TextEdit"], "Linux": ["gedit", "kate", "nano", "vim"], }, "计算器": { "Windows": ["calc.exe", "calculator.exe"], "Darwin": ["Calculator"], "Linux": ["gnome-calculator", "kcalc", "galculator"], }, "Word": { "Windows": ["WINWORD.EXE", "winword.exe"], "Darwin": ["Microsoft Word"], "Linux": ["libreoffice-writer", "writer"], }, "Excel": { "Windows": ["EXCEL.EXE", "excel.exe"], "Darwin": ["Microsoft Excel"], "Linux": ["libreoffice-calc", "calc"], }, "PowerPoint": { "Windows": ["POWERPNT.EXE", "powerpnt.exe"], "Darwin": ["Microsoft PowerPoint"], "Linux": ["libreoffice-impress", "impress"], }, "WPS": { "Windows": ["wps.exe", "et.exe", "wpp.exe"], "Darwin": ["WPS Office"], "Linux": ["wps", "et", "wpp"], }, # 浏览器 "Chrome": { "Windows": ["chrome.exe"], "Darwin": ["Google Chrome"], "Linux": ["google-chrome", "chrome"], }, "Firefox": { "Windows": ["firefox.exe"], "Darwin": ["Firefox"], "Linux": ["firefox"], }, "Edge": { "Windows": ["msedge.exe"], "Darwin": ["Microsoft Edge"], "Linux": ["microsoft-edge"], }, "Safari": {"Windows": ["Safari.exe"], "Darwin": ["Safari"], "Linux": []}, # 开发工具 "VS Code": { "Windows": ["Code.exe"], "Darwin": ["Visual Studio Code"], "Linux": ["code"], }, "VSCode": { "Windows": ["Code.exe"], "Darwin": ["Visual Studio Code"], "Linux": ["code"], }, "PyCharm": { "Windows": ["pycharm64.exe", "pycharm.exe"], "Darwin": ["PyCharm"], "Linux": ["pycharm"], }, "IntelliJ IDEA": { "Windows": ["idea64.exe", "idea.exe"], "Darwin": ["IntelliJ IDEA"], "Linux": ["idea"], }, # 媒体软件 "网易云音乐": { "Windows": ["cloudmusic.exe"], "Darwin": ["NeteaseMusic"], "Linux": ["netease-cloud-music"], }, "QQ音乐": {"Windows": ["QQMusic.exe"], "Darwin": ["QQMusic"], "Linux": ["qqmusic"]}, "VLC": {"Windows": ["vlc.exe"], "Darwin": ["VLC"], "Linux": ["vlc"]}, # 游戏平台 "Steam": {"Windows": ["steam.exe"], "Darwin": ["Steam"], "Linux": ["steam"]}, "Epic Games": { "Windows": ["EpicGamesLauncher.exe"], "Darwin": ["Epic Games Launcher"], "Linux": ["epic-games-launcher"], }, # 系统工具 "任务管理器": { "Windows": ["Taskmgr.exe"], "Darwin": ["Activity Monitor"], "Linux": ["gnome-system-monitor", "htop", "top"], }, "命令提示符": { "Windows": ["cmd.exe"], "Darwin": ["Terminal"], "Linux": ["gnome-terminal", "konsole", "xterm"], }, "PowerShell": { "Windows": ["powershell.exe", "pwsh.exe"], "Darwin": ["pwsh"], "Linux": ["pwsh"], }, # 安全软件 "360安全卫士": {"Windows": ["360Safe.exe", "360sd.exe"], "Darwin": [], "Linux": []}, "腾讯电脑管家": { "Windows": ["QQPCMgr.exe", "QQPCRTP.exe"], "Darwin": [], "Linux": [], }, # 下载工具 "迅雷": { "Windows": ["Thunder.exe", "ThunderVIP.exe"], "Darwin": ["Thunder"], "Linux": [], }, "百度网盘": { "Windows": ["BaiduNetdisk.exe"], "Darwin": ["BaiduNetdisk"], "Linux": ["baidunetdisk"], }, } class AppMapper: """应用名称映射器""" def __init__(self): self._process_cache: dict[str, set[str]] = {} def get_process_names(self, app_name: str) -> list[str]: """ 根据应用名称获取所有平台的进程名列表(合并去重) Args: app_name: 友好的应用名称 Returns: 所有平台进程名的合并列表,如果应用不存在则返回空列表 """ if app_name not in APP_MAPPING: return [] # 使用缓存提高性能 if app_name in self._process_cache: return list(self._process_cache[app_name]) # 合并所有平台的进程名 all_processes = set() platform_mapping = APP_MAPPING[app_name] for platform_processes in platform_mapping.values(): all_processes.update(platform_processes) # 缓存结果 self._process_cache[app_name] = all_processes return list(all_processes) def expand_app_names(self, app_names: list[str]) -> list[str]: """ 将友好的应用名称列表扩展为实际的进程名列表 Args: app_names: 友好的应用名称列表 Returns: 扩展后的进程名列表(去重) """ expanded_names = set() for app_name in app_names: # 如果是友好名称,获取对应的进程名 process_names = self.get_process_names(app_name) if process_names: expanded_names.update(process_names) else: # 如果不是友好名称,直接添加(可能是用户直接配置的进程名) expanded_names.add(app_name) return list(expanded_names) def get_supported_apps(self) -> list[str]: """ 获取支持的应用名称列表 Returns: 支持的友好应用名称列表 """ return list(APP_MAPPING.keys()) def get_app_info(self, app_name: str) -> dict[str, list[str]]: """ 获取应用在所有平台的进程名信息 Args: app_name: 友好的应用名称 Returns: 包含所有平台进程名的字典 """ return APP_MAPPING.get(app_name, {}) def is_supported_app(self, app_name: str) -> bool: """ 检查是否为支持的应用名称 Args: app_name: 应用名称 Returns: 是否为支持的应用名称 """ return app_name in APP_MAPPING # 全局应用映射器实例 app_mapper = AppMapper() def get_process_names_for_app(app_name: str) -> list[str]: """ 便捷函数:获取应用对应的进程名列表 Args: app_name: 友好的应用名称 Returns: 当前平台对应的进程名列表 """ return app_mapper.get_process_names(app_name) def expand_blacklist_apps(app_names: list[str]) -> list[str]: """ 便捷函数:扩展黑名单应用列表 Args: app_names: 友好的应用名称列表 Returns: 扩展后的进程名列表 """ return app_mapper.expand_app_names(app_names) if __name__ == "__main__": # 测试代码 print(f"当前平台: {platform.system()}") print(f"支持的应用数量: {len(APP_MAPPING)}") # 测试几个应用 test_apps = ["微信", "QQ", "Chrome", "VS Code", "记事本"] for app in test_apps: processes = get_process_names_for_app(app) print(f"{app}: {processes}") # 测试扩展功能 print("\n扩展测试:") expanded = expand_blacklist_apps(["微信", "Chrome", "unknown_app.exe"]) print(f"扩展结果: {expanded}") # 测试图标映射 print("\n图标映射测试:") test_icons = ["微信", "Chrome", "VS Code", "WeChat.exe"] for app in test_icons: icon = get_icon_filename(app) print(f"{app}: {icon}") ================================================ FILE: lifetrace/util/base_paths.py ================================================ """ 基础路径工具模块 提供不依赖运行时配置的路径获取函数。 """ from __future__ import annotations import os import sys from pathlib import Path def get_app_root() -> Path: """ 获取应用程序根目录,兼容开发环境 + PyInstaller 打包环境。 - 开发环境:返回 lifetrace 包所在的项目根(lifetrace/) - 打包环境:返回可执行文件所在目录(backend/,与 _internal 同级别) Returns: Path: 应用程序根目录路径 """ # PyInstaller 冻结环境 if getattr(sys, "frozen", False): # one-folder 模式:EXE 在 backend/lifetrace,内部依赖在 backend/_internal # 返回 backend/ 目录(可执行文件的父目录) exe_dir = Path(sys.executable).resolve().parent return exe_dir # 开发环境:当前文件在 lifetrace/util/path_utils.py # 返回 lifetrace/ 目录 return Path(__file__).resolve().parent.parent def get_internal_root() -> Path: """ 获取 PyInstaller 打包后的内部资源根目录(_internal), 开发环境下则退化为 app_root。 Returns: Path: 内部资源根目录路径 """ app_root = get_app_root() if getattr(sys, "frozen", False): # 打包结构:backend/ # - lifetrace (可执行文件) # - _internal/ (所有依赖和 data) internal = app_root / "_internal" if internal.exists(): return internal return app_root def get_config_dir() -> Path: """ 获取内置配置所在目录(default_config.yaml, prompt.yaml, rapidocr_config.yaml 等)。 - 开发环境:lifetrace/config/ - 打包环境:backend/config/(与 _internal 同级别,不在 _internal 内) Returns: Path: 配置目录路径 """ return get_app_root() / "config" def get_models_dir() -> Path: """ 获取内置模型目录(ONNX 等)。 - 开发环境:lifetrace/models/ - 打包环境:backend/models/(与 _internal 同级别,不在 _internal 内) Returns: Path: 模型目录路径 """ return get_app_root() / "models" def get_data_directory() -> Path | None: """ 获取用户数据目录路径(从环境变量)。 如果设置了 LIFETRACE_DATA_DIR,返回该路径; 否则返回 None(表示使用应用目录)。 Returns: Path | None: 用户数据目录路径,如果未设置则返回 None """ data_dir = os.environ.get("LIFETRACE_DATA_DIR") if data_dir: return Path(data_dir).resolve() return None def get_user_config_dir() -> Path: """ 获取用户配置目录(数据目录下的 config)。 如果设置了 LIFETRACE_DATA_DIR,返回 {data_dir}/config/; 否则返回应用目录下的 config/。 Returns: Path: 用户配置目录路径 """ data_dir = get_data_directory() if data_dir: return data_dir / "config" return get_config_dir() def get_user_data_dir() -> Path: """ 获取用户数据目录(数据目录下的 data)。 如果设置了 LIFETRACE_DATA_DIR,返回 {data_dir}/data/; 否则返回应用目录下的 data/。 Returns: Path: 用户数据目录路径 """ data_dir = get_data_directory() if data_dir: return data_dir / "data" return get_app_root() / "data" def get_user_logs_dir() -> Path: """ 获取用户日志目录(数据目录下的 logs)。 如果设置了 LIFETRACE_DATA_DIR,返回 {data_dir}/logs/; 否则返回应用目录下的 logs/。 Returns: Path: 用户日志目录路径 """ data_dir = get_data_directory() if data_dir: return data_dir / "logs" return get_app_root() / "logs" ================================================ FILE: lifetrace/util/image_utils.py ================================================ """图片处理工具函数""" import base64 import os from typing import Any from lifetrace.storage import screenshot_mgr from lifetrace.util.logging_config import get_logger logger = get_logger() def get_screenshot_base64(screenshot_id: int) -> str | None: """ 从数据库读取截图并转换为base64编码 Args: screenshot_id: 截图ID Returns: base64编码的图片字符串(格式:data:image/png;base64,{base64_str}), 如果截图不存在或读取失败则返回None """ try: # 从数据库获取截图信息 screenshot = screenshot_mgr.get_screenshot_by_id(screenshot_id) if not screenshot: logger.warning(f"截图 {screenshot_id} 不存在") return None file_path = screenshot.get("file_path") if not file_path: logger.warning(f"截图 {screenshot_id} 没有文件路径") return None # 检查文件是否存在 if not os.path.exists(file_path): logger.warning(f"截图文件不存在: {file_path}") return None # 读取文件并转换为base64 with open(file_path, "rb") as f: image_data = f.read() # 转换为base64 base64_str = base64.b64encode(image_data).decode("utf-8") # 根据文件扩展名确定MIME类型 file_ext = os.path.splitext(file_path)[1].lower() mime_type_map = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", } mime_type = mime_type_map.get(file_ext, "image/png") # 返回data URI格式 return f"data:{mime_type};base64,{base64_str}" except Exception as e: logger.error(f"读取截图 {screenshot_id} 并转换为base64失败: {e}") return None def get_screenshots_base64(screenshot_ids: list[int]) -> list[dict[str, Any]]: """ 批量获取截图的base64编码 Args: screenshot_ids: 截图ID列表 Returns: 包含截图信息的列表,每个元素包含: - screenshot_id: 截图ID - base64_data: base64编码的图片字符串(如果成功) - error: 错误信息(如果失败) """ results = [] for screenshot_id in screenshot_ids: base64_data = get_screenshot_base64(screenshot_id) if base64_data: results.append({"screenshot_id": screenshot_id, "base64_data": base64_data}) else: results.append( { "screenshot_id": screenshot_id, "error": f"截图 {screenshot_id} 读取失败", } ) return results def validate_image_format(file_path: str) -> bool: """ 验证图片格式是否支持 Args: file_path: 图片文件路径 Returns: 如果格式支持返回True,否则返回False """ supported_formats = {".png", ".jpg", ".jpeg", ".gif", ".webp"} file_ext = os.path.splitext(file_path)[1].lower() return file_ext in supported_formats ================================================ FILE: lifetrace/util/language.py ================================================ """Language utility functions: parse request language and generate language instructions""" from fastapi import Request # Language instruction mapping - add new languages here LANGUAGE_INSTRUCTIONS: dict[str, str] = { "zh": "\n\n**语言要求:请始终使用中文回答。**", "en": "\n\n**Language requirement: Always respond in English.**", # Future languages can be added here: # "ja": "\n\n**言語要件:常に日本語で回答してください。**", # "ko": "\n\n**언어 요구 사항: 항상 한국어로 답변하세요.**", # "ru": "\n\n**Требование к языку: Всегда отвечайте на русском языке.**", # "fr": "\n\n**Exigence linguistique: Répondez toujours en français.**", } # Supported locales list (derived from LANGUAGE_INSTRUCTIONS keys) SUPPORTED_LOCALES: list[str] = list(LANGUAGE_INSTRUCTIONS.keys()) # Default locale when no match is found DEFAULT_LOCALE: str = "en" def get_request_language(request: Request) -> str: """Parse language from request headers Args: request: FastAPI request object Returns: Language code (e.g., "zh", "en") """ accept_lang = request.headers.get("Accept-Language", DEFAULT_LOCALE).lower() # Match against supported locales by prefix for locale in SUPPORTED_LOCALES: if accept_lang.startswith(locale): return locale return DEFAULT_LOCALE def get_language_instruction(lang: str) -> str: """Generate language instruction to append to system prompt Args: lang: Language code (e.g., "zh", "en") Returns: Language instruction string """ return LANGUAGE_INSTRUCTIONS.get(lang, LANGUAGE_INSTRUCTIONS[DEFAULT_LOCALE]) ================================================ FILE: lifetrace/util/logging_config.py ================================================ import os import re import sys from datetime import UTC, datetime from loguru import logger def _get_local_date_string() -> str: """获取当前本地日期字符串(YYYY-MM-DD)""" return datetime.now(UTC).astimezone().strftime("%Y-%m-%d") def _generate_log_file_path(log_dir: str, suffix: str = "") -> str: """ 生成带日期和序列号的日志文件路径。 格式:YYYY-MM-DD-N{suffix}.log(N 是当天第几次启动,从 0 开始) Args: log_dir: 日志目录路径 suffix: 文件名后缀(如 ".error") Returns: 完整的日志文件路径 """ date_str = _get_local_date_string() # 匹配当天的日志文件,格式:YYYY-MM-DD-N.log 或 YYYY-MM-DD-N.error.log pattern = re.compile(rf"^{re.escape(date_str)}-(\d+){re.escape(suffix)}\.log$") # 扫描现有日志文件,找出当天的最大序列号 max_seq = -1 try: if os.path.exists(log_dir): for filename in os.listdir(log_dir): match = pattern.match(filename) if match: seq = int(match.group(1)) max_seq = max(max_seq, seq) except OSError: pass # 忽略读取错误 # 新的序列号 = 最大序列号 + 1 new_seq = max_seq + 1 filename = f"{date_str}-{new_seq}{suffix}.log" return os.path.join(log_dir, filename) class LoggerManager: def __init__(self): logger.remove() def _build_filter(self, quiet_modules: list[str] | None): if not quiet_modules: return None lowered = [item.lower() for item in quiet_modules if isinstance(item, str)] if not lowered: return None def _filter(record): name = str(record.get("name", "")).lower() module = str(record.get("module", "")).lower() function = str(record.get("function", "")).lower() file_path = "" file_info = record.get("file") if file_info is not None: file_path = str(getattr(file_info, "path", "")).lower() target = f"{name} {module} {function} {file_path}" return not any(item in target for item in lowered) return _filter def configure(self, config: dict): if "level" not in config: raise KeyError("配置中缺少 'level' 键") if "log_path" not in config: raise KeyError("配置中缺少 'log_path' 键") level = config["level"] console_level = config.get("console_level", level) file_level = config.get("file_level", level) log_path = config["log_path"] quiet_modules = config.get("quiet_modules", []) log_filter = self._build_filter(quiet_modules) # 控制台格式(使用本地时间) console_format = ( "{time:YYYY-MM-DD HH:mm:ss.SSS} | " "{level} | " "{file}:{line} | {message}" ) logger.add(sys.stderr, level=console_level, format=console_format, filter=log_filter) if log_path: # 如果 log_path 是目录或以 / 结尾,直接使用目录作为日志目录 if log_path.endswith(os.sep) or log_path.endswith("/"): log_dir = log_path.rstrip(os.sep).rstrip("/") os.makedirs(log_dir, exist_ok=True) # 生成带序列号的日志文件名(每次启动生成新文件) log_file_path = _generate_log_file_path(log_dir) error_log_path = _generate_log_file_path(log_dir, ".error") else: raise ValueError("log_path must be a directory") # 文件日志格式(使用本地时间) file_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {file}:{line} | {message}" # 添加主日志文件(静态文件名,不使用 rotation) logger.add( log_file_path, level=file_level, format=file_format, rotation=None, # 不自动轮转,每次启动一个新文件 retention=7, encoding="utf-8", filter=log_filter, ) # 添加单独的 error 日志文件 logger.add( error_log_path, level="ERROR", format=file_format, rotation=None, # 不自动轮转 retention=30, encoding="utf-8", filter=log_filter, ) def get_logger(self): return logger def setup_logging(config: dict): logger_manager = LoggerManager() logger_manager.configure(config) logger.info("Logging setup completed") def get_logger(): return logger ================================================ FILE: lifetrace/util/path_utils.py ================================================ """ 统一的路径工具模块 提供兼容开发环境和 PyInstaller 打包环境的路径获取函数 """ from __future__ import annotations import os from pathlib import Path from lifetrace.util import base_paths from lifetrace.util.settings import settings # ============================================================ # 基于配置的路径计算函数 # ============================================================ def get_database_path() -> Path: """获取数据库路径(基于配置和数据目录) Returns: Path: 数据库文件的绝对路径 """ db_path = settings.database_path if not os.path.isabs(db_path): return base_paths.get_user_data_dir() / db_path return Path(db_path) def get_screenshots_dir() -> Path: """获取截图目录 Returns: Path: 截图目录的绝对路径 """ screenshots_dir = settings.screenshots_dir if not os.path.isabs(screenshots_dir): return base_paths.get_user_data_dir() / screenshots_dir return Path(screenshots_dir) def get_attachments_dir() -> Path: """获取附件目录 Returns: Path: 附件目录的绝对路径 """ attachments_dir = settings.attachments_dir if not os.path.isabs(attachments_dir): return base_paths.get_user_data_dir() / attachments_dir return Path(attachments_dir) def get_scheduler_database_path() -> Path: """获取调度器数据库路径 Returns: Path: 调度器数据库文件的绝对路径 """ db_path = settings.scheduler.database_path if not os.path.isabs(db_path): return base_paths.get_user_data_dir() / db_path return Path(db_path) def get_vector_db_dir() -> Path: """获取向量数据库目录 Returns: Path: 向量数据库目录的绝对路径 """ persist_dir = settings.vector_db.persist_directory if not os.path.isabs(persist_dir): return base_paths.get_user_data_dir() / persist_dir return Path(persist_dir) def get_log_dir() -> Path: """获取日志目录(替代原有 log_path 属性) Returns: Path: 日志目录的绝对路径 """ return base_paths.get_user_logs_dir() # ============================================================ # 兼容旧 API 的基础路径函数(转发到 base_paths) # ============================================================ def get_app_root() -> Path: """获取应用程序根目录。""" return base_paths.get_app_root() def get_config_dir() -> Path: """获取内置配置目录。""" return base_paths.get_config_dir() def get_models_dir() -> Path: """获取内置模型目录。""" return base_paths.get_models_dir() def get_user_config_dir() -> Path: """获取用户配置目录。""" return base_paths.get_user_config_dir() ================================================ FILE: lifetrace/util/prompt_loader.py ================================================ """提示词加载器模块 从配置文件中加载 LLM 提示词 """ import yaml from lifetrace.util.base_paths import get_config_dir from lifetrace.util.logging_config import get_logger logger = get_logger() class PromptLoader: """提示词加载器""" _instance = None _prompts = None def __new__(cls): """单例模式""" if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self): """初始化提示词加载器(延迟加载配置)""" pass def _load_prompts(self): """从 prompts/ 目录或 prompt.yaml 文件加载提示词 优先从 prompts/ 目录加载所有 yaml 文件,如果目录不存在则回退到单个 prompt.yaml 文件。 """ try: config_dir = get_config_dir() prompts_dir = config_dir / "prompts" self._prompts = {} if prompts_dir.exists() and prompts_dir.is_dir(): # 新方案:从 prompts/ 目录加载所有 yaml 文件 yaml_files = list(prompts_dir.glob("*.yaml")) if yaml_files: for yaml_file in yaml_files: try: with open(yaml_file, encoding="utf-8") as f: data = yaml.safe_load(f) or {} self._prompts.update(data) except Exception as e: logger.error(f"加载提示词文件失败 ({yaml_file.name}): {e}") logger.info( f"提示词配置加载成功,从 {len(yaml_files)} 个文件中加载了 {len(self._prompts)} 个分类" ) return # 回退方案:加载单个 prompt.yaml 文件 prompt_file = config_dir / "prompt.yaml" if not prompt_file.exists(): logger.error(f"提示词配置文件不存在: {prompt_file}") return with open(prompt_file, encoding="utf-8") as f: self._prompts = yaml.safe_load(f) or {} logger.info(f"提示词配置加载成功,共 {len(self._prompts)} 个分类") except Exception as e: logger.error(f"加载提示词配置失败: {e}") self._prompts = {} def get_prompt(self, category: str, key: str, **kwargs) -> str: """ 获取提示词 Args: category: 提示词分类(如 'rag', 'llm_client', 'event_summary') key: 提示词键名 **kwargs: 格式化参数(用于替换提示词模板中的占位符) Returns: 格式化后的提示词字符串 """ try: if self._prompts is None: self._load_prompts() if self._prompts is None: self._prompts = {} # 获取提示词模板 prompt_template = self._prompts.get(category, {}).get(key, "") if not prompt_template: logger.warning(f"未找到提示词: {category}.{key}") return "" # 如果有格式化参数,进行格式化 if kwargs: return prompt_template.format(**kwargs) return prompt_template except Exception as e: logger.error(f"获取提示词失败 ({category}.{key}): {e}") return "" def reload(self): """重新加载提示词配置""" logger.info("重新加载提示词配置...") self._load_prompts() # 创建全局单例实例 prompt_loader = PromptLoader() def get_prompt(category: str, key: str, **kwargs) -> str: """ 便捷函数:获取提示词 Args: category: 提示词分类 key: 提示词键名 **kwargs: 格式化参数 Returns: 格式化后的提示词字符串 """ return prompt_loader.get_prompt(category, key, **kwargs) ================================================ FILE: lifetrace/util/query_parser.py ================================================ """查询解析器模块 将自然语言查询转换为结构化的数据库查询条件。 使用LLM理解用户意图并提取查询参数。 """ import re from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any from lifetrace.util.app_utils import app_mapper from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now, to_utc logger = get_logger() @dataclass class QueryConditions: """查询条件数据类""" # 时间范围 start_date: datetime | None = None end_date: datetime | None = None # 应用过滤 app_names: list[str] | None = None # 文本内容 keywords: list[str] | None = None # 项目过滤 project_id: int | None = None # 其他条件 limit: int = 1000 def to_dict(self) -> dict[str, Any]: """转换为字典格式""" result = {} if self.start_date: result["start_date"] = self.start_date if self.end_date: result["end_date"] = self.end_date if self.app_names: result["app_names"] = self.app_names if self.keywords: result["keywords"] = self.keywords if self.project_id: result["project_id"] = self.project_id if self.limit: result["limit"] = self.limit return result class QueryParser: """查询解析器""" def __init__(self, llm_client=None): self.llm_client = llm_client # 应用名称映射(常见的应用别名) self.app_name_mapping = { "微信": ["WeChat", "wechat", "微信"], "QQ": ["QQ", "qq", "TencentQQ"], "浏览器": [ "Chrome", "Firefox", "Edge", "Safari", "chrome", "firefox", "edge", ], "VS Code": ["Code", "vscode", "Visual Studio Code"], "记事本": ["Notepad", "notepad"], "Word": ["WINWORD", "Microsoft Word", "word"], "Excel": ["EXCEL", "Microsoft Excel", "excel"], "PowerPoint": ["POWERPNT", "Microsoft PowerPoint", "powerpoint", "ppt"], } # 时间关键词映射 self.time_keywords = { "今天": 0, "昨天": 1, "前天": 2, "本周": 7, "上周": 14, "本月": 30, "上月": 60, } def parse_query(self, query: str) -> QueryConditions: """解析自然语言查询 Args: query: 自然语言查询字符串 Returns: QueryConditions: 解析后的查询条件 """ logger.info(f"解析查询: {query}") # 如果有LLM客户端,使用LLM解析 if self.llm_client: try: parsed_data = self.llm_client.parse_query(query) # 检查LLM解析结果是否有效(至少有一个有用的字段) has_keywords = parsed_data.get("keywords") and len(parsed_data["keywords"]) > 0 has_app_names = parsed_data.get("app_names") and len(parsed_data["app_names"]) > 0 has_time_info = parsed_data.get("start_date") or parsed_data.get("end_date") if has_keywords or has_app_names or has_time_info: logger.info("LLM解析结果有效,构建QueryConditions") try: result = self._build_query_conditions(parsed_data) logger.info("=== 最终查询条件 (LLM解析) ===") logger.info(f"查询条件: {result}") return result except Exception as e: logger.warning(f"构建查询条件失败: {e}") pass else: logger.warning("缺乏有效查询条件") # return "缺乏有效查询条件" except Exception as e: logger.warning(f"LLM解析失败: {e}") # 回退到规则解析 result = self._parse_with_rules(query) logger.info("=== 最终查询条件 (规则解析) ===") logger.info(f"查询条件: {result}") return result def _parse_with_rules(self, query: str) -> QueryConditions: """使用规则解析查询""" conditions = QueryConditions() # 解析时间 conditions.start_date, conditions.end_date = self._extract_time_range(query) # 解析应用名称 conditions.app_names = self._extract_app_names(query) # 解析关键词 conditions.keywords = self._extract_keywords(query) return conditions def _extract_time_range(self, query: str) -> tuple[datetime | None, datetime | None]: """提取时间范围""" now = get_utc_now().astimezone() start_date = None end_date = None # 检查时间关键词 for keyword, days_ago in self.time_keywords.items(): if keyword in query: if keyword in ["今天"]: start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) end_date = now elif keyword in ["昨天"]: yesterday = now - timedelta(days=1) start_date = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) end_date = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999) elif keyword in ["本周"]: # 本周一开始 days_since_monday = now.weekday() start_date = (now - timedelta(days=days_since_monday)).replace( hour=0, minute=0, second=0, microsecond=0 ) end_date = now else: start_date = now - timedelta(days=days_ago) end_date = now break # 检查具体日期格式(如:2024-01-01) date_pattern = r"(\d{4}[-/]\d{1,2}[-/]\d{1,2})" dates = re.findall(date_pattern, query) if dates: try: date_str = dates[0].replace("/", "-") parsed_date = datetime.strptime(date_str, "%Y-%m-%d").astimezone() start_date = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0) end_date = parsed_date.replace(hour=23, minute=59, second=59, microsecond=999999) except ValueError: pass return ( to_utc(start_date) if start_date else None, to_utc(end_date) if end_date else None, ) def _find_app_names_from_mappings(self, query: str) -> list[str]: """从映射表中查找应用名称""" friendly_app_names = [] # 首先检查app_mapping中支持的应用名称 for app_name in app_mapper.get_supported_apps(): if app_name in query: friendly_app_names.append(app_name) # 检查传统的应用别名映射 for app_alias in self.app_name_mapping: if app_alias in query and app_alias not in friendly_app_names: friendly_app_names.append(app_alias) return friendly_app_names def _find_app_names_from_patterns(self, query: str, existing: list[str]) -> list[str]: """通过正则模式查找应用名称""" app_patterns = [ r"在([\u4e00-\u9fa5a-zA-Z0-9\s]+)上", # 在XX上 r"([\u4e00-\u9fa5a-zA-Z0-9\s]+)应用", # XX应用 r"([\u4e00-\u9fa5a-zA-Z0-9\s]+)软件", # XX软件 ] found_names = list(existing) for pattern in app_patterns: matches = re.findall(pattern, query) for match in matches: app_name = match.strip() if app_name and app_name not in found_names: found_names.append(app_name) return found_names def _convert_to_process_names(self, friendly_names: list[str]) -> list[str]: """将友好名称转换为实际进程名""" actual_process_names = [] for app_name in friendly_names: process_names = app_mapper.get_process_names(app_name) if process_names: actual_process_names.extend(process_names) else: actual_process_names.append(app_name) return actual_process_names def _extract_app_names(self, query: str) -> list[str] | None: """提取应用名称""" friendly_app_names = self._find_app_names_from_mappings(query) friendly_app_names = self._find_app_names_from_patterns(query, friendly_app_names) if friendly_app_names: return self._convert_to_process_names(friendly_app_names) return None def _extract_keywords(self, query: str) -> list[str] | None: """提取关键词 - 改进版本,区分功能描述词和搜索关键词""" keywords = [] # 检查是否有明确的搜索意图 search_indicators = ["搜索", "查找", "包含", "关于", "找到", "寻找"] has_search_intent = any(indicator in query for indicator in search_indicators) # 如果没有搜索意图,直接返回None if not has_search_intent: return None # 功能描述词列表(这些词不应该作为搜索关键词) function_words = [ "聊天", "浏览", "编辑", "查看", "打开", "使用", "运行", "操作", "活动", "记录", "情况", "状态", ] # 停用词列表 stop_words = [ "帮我", "总结", "一下", "查找", "搜索", "显示", "看看", "的", "在", "上", "中", "里", "包含", "关于", "找到", "寻找", ] # 创建查询副本用于处理 processed_query = query # 移除时间词汇 for time_word in self.time_keywords: processed_query = processed_query.replace(time_word, "") # 移除应用名称 for app_alias in self.app_name_mapping: processed_query = processed_query.replace(app_alias, "") # 分词并过滤 words = re.findall(r"[\u4e00-\u9fa5a-zA-Z0-9]+", processed_query) for word in words: if word not in stop_words and word not in function_words and len(word) > 1: keywords.append(word) return keywords if keywords else None def _build_parsing_prompt(self, query: str) -> str: """构建LLM解析提示词""" return f"""请解析以下自然语言查询,提取出结构化的查询条件。 查询: {query} 请返回JSON格式的结果,包含以下字段: - time_range: {{"start": "YYYY-MM-DD HH:MM:SS", "end": "YYYY-MM-DD HH:MM:SS"}} 或 null - app_names: ["应用名称1", "应用名称2"] 或 null - keywords: ["关键词1", "关键词2"] 或 null 常见应用名称映射: - 微信: WeChat - QQ: QQ - 浏览器: Chrome, Firefox, Edge - VS Code: Code - Word: WINWORD - Excel: EXCEL 时间解析规则: - 今天: 当天0点到现在 - 昨天: 昨天全天 - 本周: 本周一到现在 - 具体日期: 如2024-01-01 只返回JSON,不要其他解释。""" def _parse_datetime_safe(self, date_str: str | None) -> datetime | None: """安全地解析日期时间字符串""" if not date_str: return None try: return datetime.fromisoformat(date_str) except (ValueError, TypeError): return None def _extract_time_from_parsed_data( self, parsed_data: dict[str, Any] ) -> tuple[datetime | None, datetime | None]: """从解析数据中提取时间范围""" if parsed_data.get("time_range"): time_range = parsed_data["time_range"] return ( self._parse_datetime_safe(time_range.get("start")), self._parse_datetime_safe(time_range.get("end")), ) return ( self._parse_datetime_safe(parsed_data.get("start_date")), self._parse_datetime_safe(parsed_data.get("end_date")), ) def _build_query_conditions(self, parsed_data: dict[str, Any]) -> QueryConditions: """从解析数据构建查询条件""" conditions = QueryConditions() # 处理时间范围 conditions.start_date, conditions.end_date = self._extract_time_from_parsed_data( parsed_data ) # 处理应用名称 if parsed_data.get("app_names"): conditions.app_names = self._convert_to_process_names(parsed_data["app_names"]) if not conditions.app_names: conditions.app_names = None # 处理关键词 if parsed_data.get("keywords"): conditions.keywords = parsed_data["keywords"] return conditions # 创建全局实例 query_parser = QueryParser() ================================================ FILE: lifetrace/util/settings.py ================================================ """ Dynaconf 配置模块 - 支持热加载的配置管理 使用 Dynaconf 替代自定义配置类,提供: - 配置文件热加载 (reload) - 环境变量覆盖 (LIFETRACE__ 前缀) - 多配置文件合并 - 配置验证 """ import shutil from pathlib import Path from dynaconf import Dynaconf, Validator from lifetrace.util.base_paths import get_config_dir, get_user_config_dir def _get_config_dir() -> Path: """获取配置目录""" return get_user_config_dir() def _get_default_config_dir() -> Path: """获取内置默认配置目录""" return get_config_dir() def _init_config_files() -> list[str]: """初始化并返回配置文件列表 确保用户配置目录存在,如果 config.yaml 不存在则从默认配置复制。 返回按加载顺序排列的配置文件路径列表。 """ user_config_dir = _get_config_dir() default_config_dir = _get_default_config_dir() # 确保用户配置目录存在 user_config_dir.mkdir(parents=True, exist_ok=True) # 默认配置文件路径 default_config_path = default_config_dir / "default_config.yaml" user_default_config_path = user_config_dir / "default_config.yaml" user_config_path = user_config_dir / "config.yaml" # 如果用户目录没有 default_config.yaml,从内置配置复制 if not user_default_config_path.exists() and default_config_path.exists(): shutil.copy2(default_config_path, user_default_config_path) # 如果用户目录没有 config.yaml,从 default_config.yaml 复制 if not user_config_path.exists(): source = ( user_default_config_path if user_default_config_path.exists() else default_config_path ) if source.exists(): shutil.copy2(source, user_config_path) # 构建配置文件列表(按加载顺序:默认配置 -> 用户配置) settings_files = [] # 首先加载默认配置 if user_default_config_path.exists(): settings_files.append(str(user_default_config_path)) elif default_config_path.exists(): settings_files.append(str(default_config_path)) # 然后加载用户配置(覆盖默认值) if user_config_path.exists(): settings_files.append(str(user_config_path)) return settings_files # 初始化配置文件并获取路径列表 _settings_files = _init_config_files() # Dynaconf 实例 settings = Dynaconf( # 配置文件(按顺序加载,后面的覆盖前面的) settings_files=_settings_files, # 环境变量前缀:LIFETRACE__LLM__API_KEY -> llm.api_key envvar_prefix="LIFETRACE", # 嵌套分隔符:双下划线 nested_separator="__", # 启用配置合并(字典会合并而非覆盖) merge_enabled=True, # 加载 .env 文件 load_dotenv=True, # 允许小写访问 lowercase_read=True, # 验证器 validators=[ # 服务器配置 Validator("server.host", default="127.0.0.1"), Validator("server.port", default=8001, is_type_of=int), Validator("server.debug", default=False, is_type_of=bool), # 基础目录配置 Validator("base_dir", default="data"), Validator("database_path", default="lifetrace.db"), Validator("screenshots_dir", default="screenshots/"), Validator("attachments_dir", default="attachments/"), # 日志配置 Validator("logging.level", default="INFO"), Validator("logging.log_path", default="logs/"), Validator("logging.console_level", default="INFO"), Validator("logging.file_level", default="INFO"), Validator("logging.quiet_modules", default=[], is_type_of=list), # 调度器配置 Validator("scheduler.enabled", default=True, is_type_of=bool), Validator("scheduler.database_path", default="scheduler.db"), # 向量数据库配置 Validator("vector_db.enabled", default=True, is_type_of=bool), Validator("vector_db.collection_name", default="lifetrace_ocr"), Validator("vector_db.persist_directory", default="vector_db"), # 聊天配置 Validator("chat.enable_history", default=True, is_type_of=bool), Validator("chat.history_limit", default=10, is_type_of=int), # LLM 配置(关键配置,启动时不强制要求,运行时检查) Validator("llm.api_key", default="YOUR_LLM_KEY_HERE"), Validator("llm.base_url", default="https://dashscope.aliyuncs.com/compatible-mode/v1"), Validator("llm.model", default="qwen-plus"), Validator("llm.vision_model", default="qwen3-vl-plus"), Validator("llm.temperature", default=0.7), Validator("llm.max_tokens", default=2048, is_type_of=int), # Tavily 配置(联网搜索) Validator("tavily.api_key", default="YOUR_TAVILY_API_KEY_HERE"), Validator("tavily.search_depth", default="basic"), Validator("tavily.max_results", default=5, is_type_of=int), Validator("tavily.include_domains", default=[]), Validator("tavily.exclude_domains", default=[]), # 音频配置 Validator("audio.is_24x7", default=False, is_type_of=bool), Validator("audio.asr.api_key", default="YOUR_LLM_KEY_HERE"), Validator( "audio.asr.base_url", default="wss://dashscope.aliyuncs.com/api-ws/v1/inference/" ), Validator("audio.asr.model", default="fun-asr-realtime"), Validator("audio.asr.sample_rate", default=16000, is_type_of=int), Validator("audio.asr.format", default="pcm"), Validator("audio.asr.semantic_punctuation_enabled", default=False, is_type_of=bool), Validator("audio.asr.max_sentence_silence", default=1300, is_type_of=int), Validator("audio.asr.heartbeat", default=False, is_type_of=bool), Validator("audio.storage.audio_dir", default="audio/"), Validator("audio.storage.temp_audio_dir", default="temp_audio/"), # 后端模块启用配置 Validator("backend_modules.enabled", default=[], is_type_of=list), Validator("backend_modules.disabled", default=[], is_type_of=list), Validator("backend_modules.unavailable", default=[], is_type_of=list), ], ) def get_settings() -> Dynaconf: """获取 Dynaconf settings 实例""" return settings def reload_settings() -> bool: """重新加载配置文件 Returns: bool: 是否成功重载 """ try: settings.reload() return True except Exception: return False ================================================ FILE: lifetrace/util/time_parser.py ================================================ """时间解析工具函数""" import re from datetime import datetime, time, timedelta from lifetrace.util.logging_config import get_logger logger = get_logger() # 常量定义 MAX_HOUR = 23 MAX_MINUTE = 59 NOON_HOUR = 12 def _parse_24h_time(time_str: str) -> tuple[int, int] | None: """解析24小时制时间格式""" pattern_24h = r"(\d{1,2}):?(\d{2})" match = re.match(pattern_24h, time_str.strip()) if match: hour = int(match.group(1)) minute = int(match.group(2)) if 0 <= hour <= MAX_HOUR and 0 <= minute <= MAX_MINUTE: return (hour, minute) return None def _parse_12h_time(time_str: str) -> tuple[int, int] | None: """解析12小时制时间格式(如:下午3点)""" time_str_lower = time_str.lower() hour_map = { "凌晨": 0, "早上": 6, "上午": 9, "中午": 12, "下午": 13, "傍晚": 18, "晚上": 20, "深夜": 23, } for period in hour_map: if period in time_str_lower: # 提取数字 numbers = re.findall(r"\d+", time_str) if numbers: hour = int(numbers[0]) if period in ["下午", "傍晚", "晚上"] and hour < NOON_HOUR: hour += NOON_HOUR elif period == "中午" and hour == NOON_HOUR: hour = NOON_HOUR elif period in ["凌晨", "早上", "上午"] and hour == NOON_HOUR: hour = 0 minute = int(numbers[1]) if len(numbers) > 1 else 0 if 0 <= hour <= MAX_HOUR and 0 <= minute <= MAX_MINUTE: return (hour, minute) return None def parse_time_string(time_str: str) -> tuple[int, int] | None: """ 解析时间字符串,提取小时和分钟 Args: time_str: 时间字符串,如 "13:00", "下午3点", "15:30" Returns: (小时, 分钟) 元组,如果解析失败返回None """ if not time_str: return None # 先尝试24小时制格式 result = _parse_24h_time(time_str) if result: return result # 再尝试12小时制格式 result = _parse_12h_time(time_str) if result: return result logger.warning(f"无法解析时间字符串: {time_str}") return None def normalize_time_string(time_str: str) -> str: """ 标准化时间字符串为24小时制格式 Args: time_str: 原始时间字符串 Returns: 标准化后的时间字符串(HH:MM格式),如果解析失败返回原字符串 """ result = parse_time_string(time_str) if result: hour, minute = result return f"{hour:02d}:{minute:02d}" return time_str def parse_relative_time( relative_days: int, relative_time_str: str, reference_time: datetime, ) -> datetime | None: """ 解析相对时间并转换为绝对时间 Args: relative_days: 相对天数(0=今天,1=明天,2=后天,-1=昨天) relative_time_str: 相对时间点字符串(如 "13:00") reference_time: 参考时间(通常是事件的开始时间或结束时间) Returns: 解析后的绝对时间,如果解析失败返回None """ try: # 解析时间字符串 time_result = parse_time_string(relative_time_str) if not time_result: logger.warning(f"无法解析相对时间字符串: {relative_time_str}") return None hour, minute = time_result # 计算目标日期 target_date = reference_time.date() + timedelta(days=relative_days) # 组合日期和时间 target_datetime = datetime.combine(target_date, time(hour, minute)) # 如果目标时间早于参考时间,且relative_days为0,可能需要调整到明天 if relative_days == 0 and target_datetime < reference_time: # 可能是"今天下午1点",但现在已经过了,可能指的是明天 # 这里保持原逻辑,由调用方决定是否调整 pass return target_datetime except Exception as e: logger.error(f"解析相对时间失败: {e}") return None def parse_absolute_time(absolute_time_str: str | datetime) -> datetime | None: """ 解析绝对时间 Args: absolute_time_str: 绝对时间字符串(ISO格式)或datetime对象 Returns: 解析后的datetime对象,如果解析失败返回None """ if isinstance(absolute_time_str, datetime): return absolute_time_str if not absolute_time_str: return None try: # 尝试解析ISO格式 if "T" in absolute_time_str or " " in absolute_time_str: # ISO格式:2024-01-15T13:00:00 或 2024-01-15 13:00:00 dt = datetime.fromisoformat(absolute_time_str.replace(" ", "T")) return dt else: # 日期格式:2024-01-15 dt = datetime.fromisoformat(absolute_time_str) return dt except Exception as e: logger.warning(f"解析绝对时间失败: {absolute_time_str}, 错误: {e}") return None def calculate_scheduled_time(time_info: dict, reference_time: datetime) -> datetime | None: """ 根据时间信息计算计划时间 Args: time_info: 时间信息字典,包含time_type、relative_days、relative_time、absolute_time等 reference_time: 参考时间(事件开始时间或结束时间) Returns: 计算后的绝对时间,如果计算失败返回None """ time_type = time_info.get("time_type") if time_type == "absolute": absolute_time = time_info.get("absolute_time") if absolute_time: return parse_absolute_time(absolute_time) return None elif time_type == "relative": relative_days = time_info.get("relative_days") relative_time_str = time_info.get("relative_time") if relative_days is not None and relative_time_str: return parse_relative_time(relative_days, relative_time_str, reference_time) return None else: logger.warning(f"未知的时间类型: {time_type}") return None ================================================ FILE: lifetrace/util/time_utils.py ================================================ """时间工具函数模块 提供 UTC 时间处理相关的工具函数,确保项目中所有时间都使用 UTC 存储和处理。 """ from __future__ import annotations import time from datetime import UTC, datetime, timedelta, timezone def get_utc_now() -> datetime: """获取当前 UTC 时间(timezone-aware) Returns: datetime: 当前 UTC 时间,带时区信息 """ return datetime.now(UTC) def to_utc(dt: datetime) -> datetime: """将 datetime 转换为 UTC 时间 Args: dt: 要转换的 datetime 对象(可以是 naive 或 timezone-aware) Returns: datetime: UTC 时间(timezone-aware) 注意: - 如果 dt 是 naive datetime(无时区信息),假设为本地时间并转换为 UTC - 如果 dt 已经是 timezone-aware,则转换为 UTC """ if dt.tzinfo is None: # naive datetime 假设为本地时间,转换为 UTC # 使用 local timezone 转换 local_tz = timezone( timedelta(seconds=-time.timezone if time.daylight == 0 else -time.altzone) ) dt_with_tz = dt.replace(tzinfo=local_tz) return dt_with_tz.astimezone(UTC) return dt.astimezone(UTC) def naive_as_utc(dt: datetime) -> datetime: """将 naive datetime 视为 UTC 时间(用于 SQLite 数据库读取) 注意:SQLite 存储 datetime 为字符串,SQLAlchemy 读取时为 naive datetime。 由于我们的代码统一使用 UTC 时间存储,数据库中的 naive datetime 实际上就是 UTC 时间。 Args: dt: naive datetime 对象 Returns: datetime: UTC timezone-aware datetime Raises: ValueError: 如果 dt 不是 naive datetime(已经有 tzinfo) """ if dt.tzinfo is not None: # 如果已经有时区信息,直接返回 return dt.astimezone(UTC) # 假设 naive datetime 就是 UTC 时间,直接添加 UTC 时区信息 return dt.replace(tzinfo=UTC) def ensure_utc(dt: datetime | None) -> datetime | None: """确保 datetime 是 UTC,如果是 None 则返回 None Args: dt: 要处理的 datetime 对象或 None Returns: datetime | None: UTC 时间(timezone-aware)或 None """ return to_utc(dt) if dt is not None else None def to_local(dt: datetime | None) -> datetime | None: """将 datetime 转换为本地时间(timezone-aware)。 如果 dt 为 naive,则视为本地时间并补充本地时区;如果已有 tzinfo,则转换到本地时区。 """ if dt is None: return None if dt.tzinfo is None: offset = -time.timezone if time.daylight == 0 else -time.altzone local_tz = timezone(timedelta(seconds=offset)) return dt.replace(tzinfo=local_tz) return dt.astimezone() ================================================ FILE: lifetrace/util/token_usage_logger.py ================================================ """ Token使用量记录器 记录LLM API调用的token使用情况,便于后续统计分析 """ from datetime import timedelta from functools import lru_cache from typing import Any from lifetrace.storage import get_session from lifetrace.storage.models import TokenUsage from lifetrace.storage.sql_utils import col from lifetrace.util.logging_config import get_logger from lifetrace.util.settings import settings from lifetrace.util.time_utils import get_utc_now logger = get_logger() def _resolve_model_price( model: str, price_config: dict, input_tokens: int | None = None, ) -> tuple[float, float]: """根据价格配置解析模型的单价(元/千token) 支持分层定价(tiers)和旧版的 input_price/output_price 直配。 Args: model: 模型名称 price_config: 价格配置字典 input_tokens: 输入token数量,用于选择分层价格(可选) Returns: (input_price, output_price) 元组 """ # 支持分层定价:tiers 为列表,按 max_input_tokens 升序匹配 if "tiers" in price_config: tiers = price_config.get("tiers") or [] if not isinstance(tiers, list) or not tiers: raise ValueError(f"模型 '{model}' 的 tiers 配置无效") sorted_tiers = sorted( tiers, key=lambda tier: tier.get("max_input_tokens", float("inf")), ) tokens = input_tokens if input_tokens is not None else 0 selected_tier = None for tier in sorted_tiers: max_tokens = tier.get("max_input_tokens") # 如果未设置上限或在上限内,则匹配到该档 if max_tokens is None or tokens <= max_tokens: selected_tier = tier break if selected_tier is None: selected_tier = sorted_tiers[-1] if "input_price" not in selected_tier or "output_price" not in selected_tier: raise KeyError(f"模型 '{model}' 的 tiers 配置缺少 input_price 或 output_price。") return float(selected_tier["input_price"]), float(selected_tier["output_price"]) # 兼容旧配置:直接使用 input_price/output_price if "input_price" not in price_config or "output_price" not in price_config: raise KeyError( f"模型 '{model}' 的价格配置不完整。请确保配置了 input_price 和 output_price。" ) return float(price_config["input_price"]), float(price_config["output_price"]) class TokenUsageLogger: """Token使用量记录器""" def __init__(self): pass def _get_model_price(self, model: str, input_tokens: int | None = None) -> tuple[float, float]: """获取模型价格(元/千token) Args: model: 模型名称 input_tokens: 输入token数量,用于选择分层价格(可选) Returns: (input_price, output_price) 元组 """ model_prices = settings.get("llm.model_prices") if model_prices is None: return 0.0, 0.0 # 将 Dynaconf Box 对象转换为普通字典 if hasattr(model_prices, "to_dict"): model_prices = model_prices.to_dict() # 先尝试获取指定模型的价格 if model in model_prices: price_config = model_prices[model] if hasattr(price_config, "to_dict"): price_config = price_config.to_dict() return _resolve_model_price(model, price_config, input_tokens=input_tokens) # 如果没有找到,使用默认价格 if "default" not in model_prices: raise KeyError( f"找不到模型 '{model}' 的价格配置,也没有配置默认价格。" f"请在配置文件中添加该模型的价格或配置 default 价格。" ) default_config = model_prices["default"] if hasattr(default_config, "to_dict"): default_config = default_config.to_dict() return _resolve_model_price(model, default_config, input_tokens=input_tokens) def log_token_usage( self, model: str, input_tokens: int, output_tokens: int, metadata: dict[str, Any] | None = None, ): """ 记录token使用量 Args: model: 使用的模型名称 input_tokens: 输入token数量 output_tokens: 输出token数量 metadata: 元数据字典,可包含以下键: - endpoint: API端点(如 /api/chat, /api/chat/stream) - user_query: 用户查询内容(可选,用于分析) - response_type: 响应类型(如 chat, search, classify) - feature_type: 功能类型(如 event_assistant, project_assistant) - additional_info: 额外信息字典 """ max_query_preview_length = 200 if metadata is None: metadata = {} endpoint = metadata.get("endpoint") user_query = metadata.get("user_query") response_type = metadata.get("response_type") feature_type = metadata.get("feature_type") try: # 计算成本 input_price, output_price = self._get_model_price(model, input_tokens) input_cost = (input_tokens / 1000) * input_price output_cost = (output_tokens / 1000) * output_price total_cost = input_cost + output_cost # 准备用户查询预览 user_query_preview = None query_length = None if user_query: # 只记录查询的前N个字符 user_query_preview = user_query[:max_query_preview_length] + ( "..." if len(user_query) > max_query_preview_length else "" ) query_length = len(user_query) # 写入数据库 with get_session() as session: token_usage = TokenUsage( model=model, input_tokens=input_tokens, output_tokens=output_tokens, total_tokens=input_tokens + output_tokens, endpoint=endpoint, response_type=response_type, feature_type=feature_type, user_query_preview=user_query_preview, query_length=query_length, input_cost=input_cost, output_cost=output_cost, total_cost=total_cost, created_at=get_utc_now(), ) session.add(token_usage) session.flush() # 记录到标准日志 logger.info( f"Token usage - Model: {model}, Input: {input_tokens}, Output: {output_tokens}, " f"Total: {input_tokens + output_tokens}, Cost: ¥{total_cost:.4f}" ) except Exception as e: # 记录错误但不影响主流程 logger.error(f"Failed to log token usage: {e}") def get_usage_stats(self, days: int = 30) -> dict[str, Any]: """ 获取token使用统计 Args: days: 统计最近多少天的数据 Returns: 统计结果字典 """ try: stats = { "total_input_tokens": 0, "total_output_tokens": 0, "total_tokens": 0, "total_requests": 0, "total_cost": 0.0, "model_stats": {}, "endpoint_stats": {}, "feature_stats": {}, "daily_stats": {}, } end_date = get_utc_now() start_date = end_date - timedelta(days=days) # 从数据库查询 with get_session() as session: # 查询时间范围内的所有记录 records = ( session.query(TokenUsage) .filter(col(TokenUsage.created_at) >= start_date) .filter(col(TokenUsage.created_at) <= end_date) .all() ) for record in records: # 更新总计 stats["total_input_tokens"] += record.input_tokens stats["total_output_tokens"] += record.output_tokens stats["total_tokens"] += record.total_tokens stats["total_cost"] += record.total_cost stats["total_requests"] += 1 # 按模型统计 model = record.model if model not in stats["model_stats"]: stats["model_stats"][model] = { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "requests": 0, "input_cost": 0.0, "output_cost": 0.0, "total_cost": 0.0, } stats["model_stats"][model]["input_tokens"] += record.input_tokens stats["model_stats"][model]["output_tokens"] += record.output_tokens stats["model_stats"][model]["total_tokens"] += record.total_tokens stats["model_stats"][model]["input_cost"] += record.input_cost stats["model_stats"][model]["output_cost"] += record.output_cost stats["model_stats"][model]["total_cost"] += record.total_cost stats["model_stats"][model]["requests"] += 1 # 按端点统计 endpoint = record.endpoint or "unknown" if endpoint not in stats["endpoint_stats"]: stats["endpoint_stats"][endpoint] = { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "requests": 0, "total_cost": 0.0, } stats["endpoint_stats"][endpoint]["input_tokens"] += record.input_tokens stats["endpoint_stats"][endpoint]["output_tokens"] += record.output_tokens stats["endpoint_stats"][endpoint]["total_tokens"] += record.total_tokens stats["endpoint_stats"][endpoint]["total_cost"] += record.total_cost stats["endpoint_stats"][endpoint]["requests"] += 1 # 按功能类型统计 feature_type = record.feature_type or "unknown" if feature_type not in stats["feature_stats"]: stats["feature_stats"][feature_type] = { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "requests": 0, "total_cost": 0.0, } stats["feature_stats"][feature_type]["input_tokens"] += record.input_tokens stats["feature_stats"][feature_type]["output_tokens"] += record.output_tokens stats["feature_stats"][feature_type]["total_tokens"] += record.total_tokens stats["feature_stats"][feature_type]["total_cost"] += record.total_cost stats["feature_stats"][feature_type]["requests"] += 1 # 按日期统计 date_str = record.created_at.strftime("%Y-%m-%d") if date_str not in stats["daily_stats"]: stats["daily_stats"][date_str] = { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "requests": 0, "total_cost": 0.0, } stats["daily_stats"][date_str]["input_tokens"] += record.input_tokens stats["daily_stats"][date_str]["output_tokens"] += record.output_tokens stats["daily_stats"][date_str]["total_tokens"] += record.total_tokens stats["daily_stats"][date_str]["total_cost"] += record.total_cost stats["daily_stats"][date_str]["requests"] += 1 return stats except Exception as e: logger.error(f"Failed to get usage stats: {e}") return {} # 全局token使用量记录器实例 @lru_cache(maxsize=1) def get_token_logger() -> TokenUsageLogger: """获取token使用量记录器实例""" return TokenUsageLogger() def setup_token_logger() -> TokenUsageLogger: """设置token使用量记录器""" return get_token_logger() def log_token_usage(model: str, input_tokens: int, output_tokens: int, **kwargs): """便捷函数:记录token使用量 Args: model: 模型名称 input_tokens: 输入token数量 output_tokens: 输出token数量 **kwargs: 传递给 metadata 字典的其他参数 """ token_logger = get_token_logger() return token_logger.log_token_usage(model, input_tokens, output_tokens, metadata=kwargs) ================================================ FILE: lifetrace/util/utils.py ================================================ import hashlib import importlib import os import platform import shutil import subprocess # nosec B404 from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast from lifetrace.util.logging_config import get_logger from lifetrace.util.time_utils import get_utc_now logger = get_logger() # 常量定义 MIN_WINDOW_SIZE = 100 # 最小窗口尺寸(用于过滤菜单、工具栏等) BYTES_PER_KB = 1024 # 每KB的字节数 DEFAULT_SCREEN_ID = 1 # 默认屏幕ID try: import psutil import win32api import win32gui import win32process except ImportError: psutil = None win32api = None win32gui = None win32process = None def _load_appkit() -> Any | None: try: return importlib.import_module("AppKit") except Exception: return None def _load_quartz() -> Any | None: try: return importlib.import_module("Quartz") except Exception: return None def get_file_hash(file_path: str) -> str: """计算文件MD5哈希值""" hash_md5 = hashlib.md5(usedforsecurity=False) try: with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): hash_md5.update(chunk) return hash_md5.hexdigest() except Exception: return "" def ensure_dir(path: str): """确保目录存在""" os.makedirs(path, exist_ok=True) def get_active_window_info() -> tuple[str | None, str | None]: """获取当前活跃窗口信息""" try: system = platform.system() if system == "Windows": return _get_windows_active_window() elif system == "Darwin": # macOS return _get_macos_active_window() elif system == "Linux": return _get_linux_active_window() else: return None, None except Exception as e: logger.warning(f"获取活跃窗口信息失败: {e}") return None, None def _get_windows_active_window() -> tuple[str | None, str | None]: """获取Windows活跃窗口信息""" try: if psutil is None or win32gui is None or win32process is None: logger.warning("Windows依赖未安装,无法获取窗口信息") return None, None hwnd = win32gui.GetForegroundWindow() if hwnd: window_title = win32gui.GetWindowText(hwnd) _, pid = win32process.GetWindowThreadProcessId(hwnd) try: process = psutil.Process(pid) app_name = process.name() except: # noqa: E722 app_name = None return app_name, window_title except Exception as e: logger.error(f"获取Windows窗口信息失败: {e}") return None, None def _get_macos_active_window() -> tuple[str | None, str | None]: """获取macOS活跃窗口信息""" try: appkit = _load_appkit() quartz = _load_quartz() if appkit is None or quartz is None: logger.warning("macOS依赖未安装,无法获取窗口信息") return None, None # 获取活跃应用 workspace = appkit.NSWorkspace.sharedWorkspace() active_app = workspace.activeApplication() app_name = active_app.get("NSApplicationName", None) if active_app else None # 获取窗口标题 try: window_list = quartz.CGWindowListCopyWindowInfo( quartz.kCGWindowListOptionOnScreenOnly, quartz.kCGNullWindowID ) if window_list: for window in window_list: if window.get("kCGWindowOwnerName") == app_name: window_title = window.get("kCGWindowName", "") if window_title: return app_name, window_title except Exception as window_error: # 可能是权限问题,返回应用名称但不返回窗口标题 logger.warning(f"无法获取窗口标题(可能缺少屏幕录制权限): {window_error}") return app_name, None return app_name, None except Exception as e: logger.error(f"获取macOS窗口信息失败: {e}") return None, None def get_active_window_screen() -> int | None: """获取活跃窗口所在的屏幕ID(从1开始)""" try: system = platform.system() if system == "Darwin": # macOS return _get_macos_active_window_screen() elif system == "Windows": return _get_windows_active_window_screen() elif system == "Linux": return _get_linux_active_window_screen() else: return None except Exception as e: logger.warning(f"获取活跃窗口屏幕失败: {e}") return None def _get_macos_active_app_name() -> str | None: """获取macOS活跃应用名称""" appkit = _load_appkit() if appkit is None: return None workspace = appkit.NSWorkspace.sharedWorkspace() active_app = workspace.activeApplication() if not active_app: return None return active_app.get("NSApplicationName", None) def _get_macos_active_window_bounds(app_name: str) -> dict | None: """获取macOS活跃窗口的边界""" quartz = _load_quartz() if quartz is None: return None window_list = quartz.CGWindowListCopyWindowInfo( quartz.kCGWindowListOptionOnScreenOnly, quartz.kCGNullWindowID ) if not window_list: return None for window in window_list: if window.get("kCGWindowOwnerName") == app_name: bounds = window.get("kCGWindowBounds", {}) # 忽略太小的窗口(可能是菜单、工具栏等) if ( bounds.get("Height", 0) > MIN_WINDOW_SIZE and bounds.get("Width", 0) > MIN_WINDOW_SIZE ): return bounds return None def _find_screen_for_window_center(window_center: tuple[float, float], screens: list) -> int: """查找包含窗口中心点的屏幕""" window_center_x, window_center_y = window_center main_screen_height = screens[0].frame().size.height for i, screen in enumerate(screens): frame = screen.frame() screen_x = frame.origin.x screen_y = frame.origin.y screen_width = frame.size.width screen_height = frame.size.height # 转换为窗口坐标系(翻转 y 轴) screen_y_flipped = main_screen_height - screen_y - screen_height if ( screen_x <= window_center_x <= screen_x + screen_width and screen_y_flipped <= window_center_y <= screen_y_flipped + screen_height ): return i + 1 return DEFAULT_SCREEN_ID def _get_macos_active_window_screen() -> int | None: """获取macOS活跃窗口所在的屏幕ID""" try: appkit = _load_appkit() if appkit is None: logger.warning("macOS依赖未安装,无法获取屏幕信息") return None app_name = _get_macos_active_app_name() if not app_name: return None active_window_bounds = _get_macos_active_window_bounds(app_name) if not active_window_bounds: return DEFAULT_SCREEN_ID # 计算窗口中心点 window_x = active_window_bounds.get("X", 0) window_y = active_window_bounds.get("Y", 0) window_width = active_window_bounds.get("Width", 0) window_height = active_window_bounds.get("Height", 0) window_center = (window_x + window_width / 2, window_y + window_height / 2) screens = appkit.NSScreen.screens() if not screens: return DEFAULT_SCREEN_ID return _find_screen_for_window_center(window_center, screens) except Exception as e: logger.error(f"获取macOS活跃窗口屏幕失败: {e}") return None def _get_windows_active_window_screen() -> int | None: """获取Windows活跃窗口所在的屏幕ID""" try: if win32api is None or win32gui is None: logger.warning("Windows依赖未安装,无法获取屏幕信息") return None hwnd = win32gui.GetForegroundWindow() if not hwnd: return None # 获取窗口矩形 rect = win32gui.GetWindowRect(hwnd) window_x = rect[0] window_y = rect[1] window_width = rect[2] - rect[0] window_height = rect[3] - rect[1] # 计算窗口中心点 center_x = window_x + window_width // 2 center_y = window_y + window_height // 2 # 获取所有显示器 monitors = win32api.EnumDisplayMonitors() # 遍历所有显示器,找到包含窗口中心点的显示器 for i, monitor in enumerate(monitors): monitor_handle = cast("int", monitor[0]) monitor_info = win32api.GetMonitorInfo(monitor_handle) monitor_rect = monitor_info["Monitor"] if ( monitor_rect[0] <= center_x <= monitor_rect[2] and monitor_rect[1] <= center_y <= monitor_rect[3] ): return i + 1 return 1 # 默认返回主屏幕 except Exception as e: logger.error(f"获取Windows活跃窗口屏幕失败: {e}") return None def _parse_linux_window_position(stdout: str) -> tuple[int, int] | None: """解析Linux窗口位置""" for line in stdout.split("\n"): if "Position:" in line: pos = line.split("Position:")[1].split()[0] x, y = map(int, pos.split(",")) return x, y return None def _find_linux_screen_for_position(x: int, y: int, xrandr_stdout: str) -> int: """根据位置查找Linux屏幕ID""" screen_id = 1 for xrandr_line in xrandr_stdout.split("\n"): if " connected" not in xrandr_line or "+" not in xrandr_line: continue for part in xrandr_line.split(): if "+" not in part or "x" not in part: continue screen_x = int(part.split("+")[1]) screen_y = int(part.split("+")[2]) screen_width = int(part.split("x")[0]) screen_height = int(part.split("x")[1].split("+")[0]) if ( screen_x <= x <= screen_x + screen_width and screen_y <= y <= screen_y + screen_height ): return screen_id screen_id += 1 return DEFAULT_SCREEN_ID def _get_linux_active_window_screen() -> int | None: # noqa: PLR0911 """获取Linux活跃窗口所在的屏幕ID""" try: xdotool_path = shutil.which("xdotool") if not xdotool_path: return DEFAULT_SCREEN_ID result = subprocess.run( # nosec B603 [xdotool_path, "getactivewindow", "getwindowgeometry"], capture_output=True, text=True, check=False, ) if result.returncode != 0: return DEFAULT_SCREEN_ID position = _parse_linux_window_position(result.stdout) if not position: return DEFAULT_SCREEN_ID xrandr_path = shutil.which("xrandr") if not xrandr_path: return DEFAULT_SCREEN_ID xrandr_result = subprocess.run( # nosec B603 [xrandr_path, "--current"], capture_output=True, text=True, check=False, ) if xrandr_result.returncode != 0: return DEFAULT_SCREEN_ID return _find_linux_screen_for_position(position[0], position[1], xrandr_result.stdout) except Exception as e: logger.error(f"获取Linux活跃窗口屏幕失败: {e}") return None def _get_linux_active_window() -> tuple[str | None, str | None]: """获取Linux活跃窗口信息""" try: xprop_path = shutil.which("xprop") if not xprop_path: return None, None # 使用xprop获取活跃窗口ID result = subprocess.run( # nosec B603 [xprop_path, "-root", "_NET_ACTIVE_WINDOW"], capture_output=True, text=True, check=False, ) if result.returncode == 0: window_id = result.stdout.strip().split()[-1] # 获取窗口标题 title_result = subprocess.run( # nosec B603 [xprop_path, "-id", window_id, "WM_NAME"], capture_output=True, text=True, check=False, ) if title_result.returncode == 0: window_title = ( title_result.stdout.strip().split('"')[1] if '"' in title_result.stdout else None ) # 获取应用名称 class_result = subprocess.run( # nosec B603 [xprop_path, "-id", window_id, "WM_CLASS"], capture_output=True, text=True, check=False, ) if class_result.returncode == 0: app_name = ( class_result.stdout.strip().split('"')[-2] if '"' in class_result.stdout else None ) return app_name, window_title except Exception as e: logger.error(f"获取Linux窗口信息失败: {e}") return None, None def format_file_size(size_bytes: int) -> str: """格式化文件大小""" if size_bytes == 0: return "0 B" size_names = ["B", "KB", "MB", "GB", "TB"] size_value = float(size_bytes) i = 0 while size_value >= BYTES_PER_KB and i < len(size_names) - 1: size_value /= float(BYTES_PER_KB) i += 1 return f"{size_value:.1f} {size_names[i]}" def get_screenshot_filename(screen_id: int = 0, timestamp: datetime | None = None) -> str: """生成截图文件名""" if timestamp is None: timestamp = get_utc_now() return f"screen_{screen_id}_{timestamp.strftime('%Y%m%d_%H%M%S_%f')[:-3]}.png" def cleanup_old_files(directory: str, max_days: int): """清理旧文件""" if max_days <= 0: return cutoff_time = get_utc_now() - timedelta(days=max_days) for file_path in Path(directory).glob("*.png"): try: if datetime.fromtimestamp(file_path.stat().st_mtime, tz=UTC) < cutoff_time: file_path.unlink() logger.info(f"清理旧文件: {file_path}") except Exception as e: logger.error(f"清理文件失败 {file_path}: {e}") ================================================ FILE: pyproject.toml ================================================ [project] name = "lifetrace" version = "0.1.2" description = "LifeTrace - A cross-platform screen recording and activity tracking application" readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ # Core dependencies "fastapi>=0.100.0", "uvicorn[standard]>=0.20.0", "pydantic>=2.0.0", "sqlalchemy>=2.0.0", "sqlmodel>=0.0.22", "alembic>=1.14.0", "python-multipart>=0.0.6", # 文件上传支持(Form data) "icalendar>=6.0.0", # iCalendar (ICS) 导入/导出 # Screenshot and image processing "mss>=9.0.0", "Pillow>=10.0.0", "imagehash>=4.3.0", "opencv-python>=4.8.0", # For proactive OCR image processing # OCR processing (rapidocr requires numpy) "rapidocr-onnxruntime", "numpy>=2.4.2", # Audio processing (for ASR WebSocket stream) "websockets>=16.0", # WebSocket 客户端(用于连接 WhisperLiveKit 服务器) "python-socks>=2.0.0", # SOCKS 代理支持(websockets 库的可选依赖) # FunASR 语音识别(可选,需要 Visual C++ 构建工具) # 如果安装失败,系统音频实时识别功能将不可用 # 安装方法: # 1. 安装 Visual C++ 构建工具:https://visualstudio.microsoft.com/visual-cpp-build-tools/ # 2. 运行:pip install funasr # "funasr>=1.0.0", # 注释掉,作为可选依赖 # Data processing "pyyaml>=6.0", "sentence-transformers>=2.2.0", # 文本嵌入模型 "chromadb>=1.4.1", # 向量数据库 "scipy>=1.9.0", # 科学计算(用于余弦距离) "hdbscan>=0.8.0", # 文本聚类算法 # Scheduler "apscheduler>=3.10.0", # Utils "psutil>=7.2.0", # OpenAI API "openai>=2.16.0", # DashScope API (阿里云百炼) "dashscope>=1.25.0", # Agno Agent Framework "agno>=2.4.8", # DuckDuckGo search for Agno Agent "ddgs>=9.10.0", # Tavily API for web search "tavily-python>=0.7.0", # Configuration management with hot reload support (includes yaml support) "dynaconf[yaml]>=3.2.0", # Logging "loguru>=0.7.3", # Observability (Phoenix + OpenInference) "arize-phoenix>=12.33.0", # Phoenix 服务器和 UI(包含 phoenix serve 命令) "arize-phoenix-otel>=0.14.0", # Phoenix OTEL 集成 "openinference-instrumentation>=0.1.0", # OpenInference 基础工具(using_session 等) "openinference-instrumentation-agno>=0.1.0", # Agno 自动 instrument "opentelemetry-sdk>=1.39.0", "opentelemetry-exporter-otlp-proto-http>=1.39.0", # macOS specific dependencies (only install on macOS) "pyobjc-framework-Cocoa>=12.1; sys_platform == 'darwin'", "pyobjc-framework-Quartz>=12.1; sys_platform == 'darwin'", # Windows specific dependencies (only install on Windows) "pywin32>=306; sys_platform == 'win32'", "openinference-instrumentation-openai>=0.1.41", "agno-infra>=1.0.7", ] [dependency-groups] # 开发工具 dev = [ "bandit>=1.9.3", "pre-commit>=4.5.1", "pytest>=9.0.2", "pyright>=1.1.408", "pyinstaller>=6.18.0", "ruff>=0.14.14", ] [tool.pytest] testpaths = ["tests"] norecursedirs = [".venv", "build", "dist"] # Ruff 配置 - Python linter 和 formatter [tool.ruff] # 每行最大字符数(默认 88,可以根据需要调整) line-length = 100 # 目标 Python 版本 target-version = "py312" # 排除的目录 exclude = [".git", ".venv", "__pycache__", "build", "dist", "*.egg-info"] [tool.ruff.format] # 使用双引号 quote-style = "double" # 缩进使用 4 个空格 indent-style = "space" [tool.ruff.lint] # 启用的规则集 select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "B", # flake8-bugbear(高价值的潜在 bug 检查) "C4", # flake8-comprehensions(更合理的推导式) "SIM", # flake8-simplify(简化 if / boolean / loop) "UP", # pyupgrade(现代 Python 写法) "PL", # Pylint(逻辑 / 可维护性) "C90", # mccabe(复杂度) "A", # flake8-builtins(避免覆盖内建名) "ARG", # flake8-unused-arguments "N", # pep8-naming(命名规范) "TC", # flake8-type-checking "FA", # future annotations "ISC", # implicit string concatenation "FLY", # flynt(f-string 转换) "DTZ", # flake8-datetimez(时区相关) "RUF", # Ruff 原生经验规则 ] # 忽略的规则 ignore = [ "E501", # 行太长(已经通过 line-length 控制) "B008", # 允许在函数参数默认值中使用函数调用(FastAPI Depends 模式) "RUF001", # 中文 / 全角标点 "RUF002", "RUF003", ] # McCabe 复杂度配置 [tool.ruff.lint.mccabe] # 最大圈复杂度(软限制:10,硬限制:15) max-complexity = 10 [tool.ruff.lint.per-file-ignores] # 测试文件可以使用 assert "tests/*.py" = ["S101"] # Pylint 复杂度配置 [tool.ruff.lint.pylint] # 函数最大语句数(软限制:50,硬限制:100) max-statements = 50 # 函数最大参数数 max-args = 7 # 函数最大返回语句数 max-returns = 6 # 函数最大分支数 max-branches = 12 # 类中最大公共方法数(帮助控制单个文件大小) max-public-methods = 20 [tool.uv] index-url = "https://pypi.tuna.tsinghua.edu.cn/simple" extra-index-url = ["https://pypi.org/simple"] ================================================ FILE: pyrightconfig.json ================================================ { "typeCheckingMode": "standard", "pythonVersion": "3.12", "venvPath": ".", "venv": ".venv", "include": ["lifetrace", "scripts"], "exclude": [ "**/__pycache__", ".venv", ".venv/**", "build", "build/**", "dist", "dist/**", "lifetrace/dist", "lifetrace/dist/**", "lifetrace/data", "lifetrace/data/**", "lifetrace/migrations/versions" ], "reportMissingTypeStubs": "none" } ================================================ FILE: requirements-runtime.txt ================================================ fastapi>=0.100.0 uvicorn[standard]>=0.20.0 pydantic>=2.0.0 sqlalchemy>=2.0.0 sqlmodel>=0.0.22 alembic>=1.14.0 python-multipart>=0.0.6 mss>=9.0.0 Pillow>=10.0.0 imagehash>=4.3.0 opencv-python>=4.8.0 rapidocr-onnxruntime numpy>=1.21.0,<2.0.0 websockets>=12.0 python-socks>=2.0.0 pyyaml>=6.0 sentence-transformers>=2.2.0 chromadb>=0.4.0 scipy>=1.9.0 hdbscan>=0.8.0 apscheduler>=3.10.0 psutil>=5.9.0 openai>=1.0.0 dashscope>=1.17.0 agno>=2.4.1 ddgs>=8.0.0 tavily-python>=0.5.0 dynaconf[yaml]>=3.2.0 loguru>=0.7.3 pyobjc-framework-Cocoa>=9.0; sys_platform == "darwin" pyobjc-framework-Quartz>=9.0; sys_platform == "darwin" pywin32>=306; sys_platform == "win32" agno-infra>=1.0.7 ================================================ FILE: scripts/git-hooks/post-checkout ================================================ #!/usr/bin/env bash set -euo pipefail root="$(git rev-parse --show-toplevel 2>/dev/null || exit 0)" # Skip main worktree where .git is a directory; worktrees have .git as a file. if [[ -d "$root/.git" ]]; then exit 0 fi if [[ -f "$root/scripts/link_worktree_deps_here.ps1" || -f "$root/scripts/link_worktree_deps_here.sh" ]]; then case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) powershell -ExecutionPolicy Bypass -File "$root/scripts/link_worktree_deps_here.ps1" ;; *) bash "$root/scripts/link_worktree_deps_here.sh" ;; esac fi ================================================ FILE: scripts/install.ps1 ================================================ param( [string]$Dir = $env:LIFETRACE_DIR, [string]$Repo = $env:LIFETRACE_REPO, [Alias("r")] [string]$Ref = $env:LIFETRACE_REF, [Alias("m")] [string]$Mode = $env:LIFETRACE_MODE, [string]$Variant = $env:LIFETRACE_VARIANT, [string]$Frontend = $env:LIFETRACE_FRONTEND, [string]$Backend = $env:LIFETRACE_BACKEND, [string]$Run = $env:LIFETRACE_RUN ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $frontendSet = $PSBoundParameters.ContainsKey("Frontend") -or [bool]$env:LIFETRACE_FRONTEND $variantSet = $PSBoundParameters.ContainsKey("Variant") -or [bool]$env:LIFETRACE_VARIANT $dirSet = $PSBoundParameters.ContainsKey("Dir") -or [bool]$env:LIFETRACE_DIR $modeSet = $PSBoundParameters.ContainsKey("Mode") -or [bool]$env:LIFETRACE_MODE $backendSet = $PSBoundParameters.ContainsKey("Backend") -or [bool]$env:LIFETRACE_BACKEND if (-not $Repo) { $Repo = "https://github.com/FreeU-group/FreeTodo.git" } if (-not $Ref) { $Ref = "main" } if (-not $Mode) { $Mode = "tauri" } if (-not $Variant) { $Variant = "web" } if (-not $Frontend) { $Frontend = "build" } if (-not $Backend) { $Backend = "script" } if (-not $Run) { $Run = "1" } function Prompt-Choice { param( [string]$Label, [string[]]$Choices, [string]$Default ) Write-Host $Label for ($i = 0; $i -lt $Choices.Count; $i++) { Write-Host " $($i + 1)) $($Choices[$i])" } $input = Read-Host "Select [default: $Default]" if ([string]::IsNullOrWhiteSpace($input)) { return $Default } if ($input -match '^\d+$') { $index = [int]$input - 1 if ($index -ge 0 -and $index -lt $Choices.Count) { return $Choices[$index] } } else { foreach ($choice in $Choices) { if ($choice -eq $input) { return $choice } } } Write-Host "Invalid choice. Using default: $Default" return $Default } if (-not $dirSet) { $repoName = [IO.Path]::GetFileNameWithoutExtension($Repo) $Dir = $repoName } if (-not $variantSet) { $Variant = Prompt-Choice "Select UI variant:" @("web", "island") "web" } if (-not $backendSet) { $Backend = Prompt-Choice "Select backend runtime:" @("script", "pyinstaller") "script" } if (-not $modeSet) { $Mode = Prompt-Choice "Select app mode:" @("tauri", "electron", "web") "tauri" } if ($Mode -eq "island") { $Mode = "tauri" $Variant = "island" $variantSet = $true } if ($Variant -eq "island" -and $Mode -eq "web") { Write-Host "Variant 'island' is not supported in web mode. Switching mode to tauri." $Mode = "tauri" } if ($Mode -eq "web" -and $Variant -ne "web") { throw "Variant '$Variant' is not supported in web mode." } $validModes = @("web", "tauri", "electron") if ($validModes -notcontains $Mode) { throw "Invalid mode: $Mode" } $validVariants = @("web", "island") if ($validVariants -notcontains $Variant) { throw "Invalid variant: $Variant" } $validFrontend = @("build", "dev") if ($validFrontend -notcontains $Frontend) { throw "Invalid frontend action: $Frontend" } $validBackend = @("script", "pyinstaller") if ($validBackend -notcontains $Backend) { throw "Invalid backend runtime: $Backend" } if ($Backend -eq "pyinstaller" -and -not $frontendSet) { $Frontend = "build" } if ($Frontend -eq "dev" -and $Backend -eq "pyinstaller") { throw "backend=pyinstaller is only supported with frontend=build." } if ($Mode -eq "tauri" -and $Frontend -eq "build" -and $Variant -eq "island") { Write-Host "Island packaging is not supported yet. Switching variant to web for build." $Variant = "web" } function Test-Command { param([string]$Name) return [bool](Get-Command $Name -ErrorAction SilentlyContinue) } $missingDeps = New-Object System.Collections.Generic.List[object] function Add-MissingDep { param( [string]$Name, [string]$Hint, [string]$WingetId = "" ) $missingDeps.Add([pscustomobject]@{ Name = $Name Hint = $Hint WingetId = $WingetId }) } function Refresh-Path { $machinePath = [System.Environment]::GetEnvironmentVariable("Path", "Machine") $userPath = [System.Environment]::GetEnvironmentVariable("Path", "User") $env:Path = "$machinePath;$userPath" } function Install-MissingDeps { if ($missingDeps.Count -eq 0) { return } $wingetDeps = $missingDeps | Where-Object { $_.WingetId } if ($wingetDeps -and -not (Test-Command "winget")) { Install-Winget | Out-Null } if ($wingetDeps -and (Test-Command "winget")) { foreach ($dep in $wingetDeps) { Write-Host "Installing $($dep.Name) with winget..." winget install --id $($dep.WingetId) -e --accept-package-agreements --accept-source-agreements } Refresh-Path } } function Filter-MissingDeps { $remaining = New-Object System.Collections.Generic.List[object] foreach ($dep in $missingDeps) { if (-not (Test-Command $dep.Name)) { $remaining.Add($dep) } } $script:missingDeps = $remaining } function Show-MissingDeps { if ($missingDeps.Count -eq 0) { return } Write-Host "Missing required dependencies:" -ForegroundColor Red foreach ($dep in $missingDeps) { Write-Host "- $($dep.Name): $($dep.Hint)" } $wingetDeps = $missingDeps | Where-Object { $_.WingetId } if ($wingetDeps -and (Test-Command "winget")) { Write-Host "Install with winget:" foreach ($dep in $wingetDeps) { Write-Host " winget install --id $($dep.WingetId) -e --accept-package-agreements --accept-source-agreements" } } elseif ($wingetDeps) { Write-Host "winget not found. Install App Installer from Microsoft Store or from https://aka.ms/getwinget" } throw "Missing required dependencies. Install them and retry." } function Install-Winget { if (Test-Command "winget") { return $true } if (-not (Test-Command "Add-AppxPackage")) { return $false } $wingetInstallerUrl = "https://aka.ms/getwinget" $installerPath = Join-Path $env:TEMP "winget.msixbundle" try { Write-Host "winget not found. Attempting to install App Installer..." Invoke-WebRequest -Uri $wingetInstallerUrl -OutFile $installerPath Add-AppxPackage -Path $installerPath Remove-Item $installerPath -Force -ErrorAction SilentlyContinue } catch { Write-Host "Automatic winget install failed." return $false } return (Test-Command "winget") } function Get-LatestFile { param( [string]$Path, [string]$Filter ) if (-not (Test-Path $Path)) { return $null } $file = Get-ChildItem -Path $Path -Filter $Filter -File -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($file) { return $file.FullName } return $null } function Find-TauriArtifact { param( [string]$FrontendDir, [string]$Variant, [string]$Backend ) $artifactBase = Join-Path $FrontendDir "dist-artifacts\tauri\$Variant\$Backend" $bundleDir = Join-Path $FrontendDir "src-tauri\target\release\bundle" $binaryDir = Join-Path $FrontendDir "src-tauri\target\release" $artifact = Get-LatestFile $binaryDir "*.exe" if ($artifact) { return $artifact } $artifact = Get-LatestFile $artifactBase "*.exe" if (-not $artifact) { $artifact = Get-LatestFile $bundleDir "*.exe" } if (-not $artifact) { $artifact = Get-LatestFile $bundleDir "*.msi" } return $artifact } function Find-ElectronArtifact { param( [string]$FrontendDir, [string]$Variant, [string]$Backend ) $artifactBase = Join-Path $FrontendDir "dist-artifacts\electron\$Variant\$Backend" $artifact = Get-LatestFile $artifactBase "*.exe" if (-not $artifact) { $artifact = Get-LatestFile $artifactBase "*.msi" } return $artifact } function Start-BuiltApp { param([string]$ArtifactPath) if (-not $ArtifactPath) { return $false } Write-Host "Launching built app: $ArtifactPath" Start-Process -FilePath $ArtifactPath | Out-Null return $true } $pythonCmd = $env:PYTHON_BIN if (-not $pythonCmd) { if (Test-Command "python") { $pythonCmd = "python" } elseif (Test-Command "python3") { $pythonCmd = "python3" } else { Add-MissingDep "python" "Python 3.12+ not found. Install Python and retry." "Python.Python.3.12" } } elseif (-not (Test-Command $pythonCmd)) { Add-MissingDep "python" "Python 3.12+ not found. Install Python and retry." "Python.Python.3.12" } if (-not (Test-Command "git")) { Add-MissingDep "git" "Install Git and retry." "Git.Git" } if (-not (Test-Command "node")) { Add-MissingDep "node" "Install Node.js 20+ and retry." "OpenJS.NodeJS.LTS" } if ($Mode -eq "tauri") { if (-not (Test-Command "cargo")) { Add-MissingDep "cargo" "Install Rust (rustup) and retry, or set LIFETRACE_MODE=web." "Rustlang.Rustup" } } Install-MissingDeps Filter-MissingDeps Show-MissingDeps if (-not $pythonCmd -or -not (Test-Command $pythonCmd)) { if (Test-Command "python") { $pythonCmd = "python" } elseif (Test-Command "python3") { $pythonCmd = "python3" } else { throw "Python 3.12+ not found after installation. Reopen your terminal and retry." } } if (-not (Test-Command "uv")) { Write-Host "Installing uv..." irm https://astral.sh/uv/install.ps1 | iex $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" } if (-not (Test-Command "pnpm")) { $pnpmInstalled = $false if (Test-Command "corepack") { try { corepack enable corepack prepare pnpm@latest --activate $pnpmInstalled = Test-Command "pnpm" } catch { Write-Host "corepack activation failed. Falling back to pnpm install script." } } if (-not $pnpmInstalled -and (Test-Command "npm")) { try { npm install -g pnpm Refresh-Path $pnpmInstalled = Test-Command "pnpm" } catch { Write-Host "npm global install failed. Falling back to pnpm install script." } } if (-not $pnpmInstalled) { Write-Host "Installing pnpm via install script..." $env:PNPM_HOME = Join-Path $env:USERPROFILE ".local\share\pnpm" if (-not (Test-Path $env:PNPM_HOME)) { New-Item -ItemType Directory -Force -Path $env:PNPM_HOME | Out-Null } $env:Path = "$env:PNPM_HOME;$env:Path" try { irm https://get.pnpm.io/install.ps1 | iex } catch { throw "pnpm install script failed. Install pnpm manually and retry." } $pnpmInstalled = Test-Command "pnpm" } if (-not $pnpmInstalled) { throw "pnpm not found after installation. Reopen your terminal and retry." } } $repoReady = $false $depsReady = $false if (Test-Path $Dir) { if (-not (Test-Path (Join-Path $Dir ".git"))) { throw "Target path '$Dir' exists and is not a git repo. Set LIFETRACE_DIR to a new folder." } Set-Location $Dir $gitStatus = git status --porcelain if ($gitStatus) { throw "Repository has local changes. Commit or stash and retry." } git fetch --depth 1 "$Repo" "$Ref" $headSha = git rev-parse HEAD $remoteSha = git rev-parse FETCH_HEAD if ($headSha -eq $remoteSha) { $repoReady = $true } } else { git clone --depth 1 --branch "$Ref" "$Repo" "$Dir" Set-Location $Dir } $venvReady = Test-Path (Join-Path (Get-Location).Path ".venv") $frontendModulesReady = Test-Path (Join-Path (Get-Location).Path "free-todo-frontend\node_modules") $depsReady = $venvReady -and $frontendModulesReady if (-not $repoReady -or -not $depsReady) { $gitStatus = git status --porcelain if ($gitStatus) { throw "Repository has local changes. Commit or stash and retry." } git fetch --depth 1 "$Repo" "$Ref" git checkout -q -B "$Ref" FETCH_HEAD uv sync $venvReady = Test-Path (Join-Path (Get-Location).Path ".venv") $frontendModulesReady = Test-Path (Join-Path (Get-Location).Path "free-todo-frontend\node_modules") $depsReady = $venvReady -and $frontendModulesReady } else { Write-Host "Repository is up to date. Skipping install steps." } if ($Run -ne "1") { Write-Host "Install complete." exit 0 } if ($Mode -eq "web") { $uvPath = (Get-Command uv).Source $backendJob = Start-Job -ScriptBlock { param($RepoDir, $UvPath, $PythonCmd) Set-Location $RepoDir & $UvPath run $PythonCmd -m lifetrace.server } -ArgumentList (Get-Location).Path, $uvPath, $pythonCmd try { Set-Location (Join-Path (Get-Location).Path "free-todo-frontend") if (-not $frontendModulesReady) { pnpm install } if ($Frontend -eq "build") { $nextDir = Join-Path (Get-Location).Path ".next" if (-not ($repoReady -and $depsReady -and (Test-Path $nextDir))) { pnpm build } else { Write-Host "Next.js build is up to date. Skipping build step." } pnpm start } else { $env:WINDOW_MODE = $Variant pnpm dev } } finally { if ($backendJob -and $backendJob.State -eq "Running") { Stop-Job $backendJob | Out-Null } if ($backendJob) { Remove-Job $backendJob -Force | Out-Null } } } elseif ($Mode -eq "tauri") { Set-Location (Join-Path (Get-Location).Path "free-todo-frontend") if (-not $frontendModulesReady) { pnpm install } if ($Frontend -eq "build") { $artifact = Find-TauriArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend if (-not ($repoReady -and $depsReady -and $artifact)) { pnpm "build:tauri:${Variant}:${Backend}:full" $artifact = Find-TauriArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend } else { Write-Host "Tauri build is up to date. Skipping build step." } if (-not (Start-BuiltApp $artifact)) { Write-Host "Build complete. Open the artifact under src-tauri\\target\\release\\bundle." } } else { $uvPath = (Get-Command uv).Source $backendJob = Start-Job -ScriptBlock { param($RepoDir, $UvPath, $PythonCmd) Set-Location $RepoDir & $UvPath run $PythonCmd -m lifetrace.server } -ArgumentList (Resolve-Path "..").Path, $uvPath, $pythonCmd $frontendJob = Start-Job -ScriptBlock { param($FrontendDir, $Variant) Set-Location $FrontendDir $env:WINDOW_MODE = $Variant pnpm dev } -ArgumentList (Get-Location).Path, $Variant try { pnpm tauri:dev } finally { if ($frontendJob -and $frontendJob.State -eq "Running") { Stop-Job $frontendJob | Out-Null } if ($frontendJob) { Remove-Job $frontendJob -Force | Out-Null } if ($backendJob -and $backendJob.State -eq "Running") { Stop-Job $backendJob | Out-Null } if ($backendJob) { Remove-Job $backendJob -Force | Out-Null } } } } else { Set-Location (Join-Path (Get-Location).Path "free-todo-frontend") if (-not $frontendModulesReady) { pnpm install } if ($Frontend -eq "build") { $artifact = Find-ElectronArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend if (-not ($repoReady -and $depsReady -and $artifact)) { pnpm "build:electron:${Variant}:${Backend}:full:dir" $artifact = Find-ElectronArtifact -FrontendDir (Get-Location).Path -Variant $Variant -Backend $Backend } else { Write-Host "Electron build is up to date. Skipping build step." } if (-not (Start-BuiltApp $artifact)) { Write-Host "Build complete. Open the artifact under dist-artifacts\\electron." } } else { if ($Backend -eq "pyinstaller") { throw "backend=pyinstaller is only supported with frontend=build." } if ($Variant -eq "island") { pnpm electron:dev:island } else { pnpm electron:dev } } } ================================================ FILE: scripts/install.sh ================================================ #!/usr/bin/env bash set -euo pipefail REPO_URL="${LIFETRACE_REPO:-https://github.com/FreeU-group/FreeTodo.git}" REF="${LIFETRACE_REF:-main}" REPO_NAME="${REPO_URL##*/}" REPO_NAME="${REPO_NAME%.git}" TARGET_DIR="${LIFETRACE_DIR:-$REPO_NAME}" MODE="${LIFETRACE_MODE:-tauri}" VARIANT="${LIFETRACE_VARIANT:-web}" FRONTEND_ACTION="${LIFETRACE_FRONTEND:-build}" BACKEND_RUNTIME="${LIFETRACE_BACKEND:-script}" RUN_AFTER_INSTALL="${LIFETRACE_RUN:-1}" DIR_SET=0 if [ -n "${LIFETRACE_DIR:-}" ]; then DIR_SET=1 fi FRONTEND_SET=0 if [ -n "${LIFETRACE_FRONTEND:-}" ]; then FRONTEND_SET=1 fi VARIANT_SET=0 if [ -n "${LIFETRACE_VARIANT:-}" ]; then VARIANT_SET=1 fi MODE_SET=0 if [ -n "${LIFETRACE_MODE:-}" ]; then MODE_SET=1 fi BACKEND_SET=0 if [ -n "${LIFETRACE_BACKEND:-}" ]; then BACKEND_SET=1 fi usage() { cat <<'EOF' Usage: install.sh [options] Options: --ref, -r Git branch or tag to clone --mode, -m web | tauri | electron | island --variant web | island --frontend build | dev --backend script | pyinstaller --repo Git repo URL --dir Target directory --run 1 to run after install, 0 to only install --help, -h Show this help message Env vars: LIFETRACE_REPO, LIFETRACE_REF, LIFETRACE_DIR LIFETRACE_MODE, LIFETRACE_VARIANT, LIFETRACE_FRONTEND, LIFETRACE_BACKEND, LIFETRACE_RUN Defaults: mode=tauri, variant=web, frontend=build, backend=script, ref=main EOF } while [ $# -gt 0 ]; do case "$1" in --ref|-r) if [ $# -lt 2 ]; then echo "Missing value for --ref." >&2 exit 1 fi REF="$2" shift 2 ;; --mode|-m) if [ $# -lt 2 ]; then echo "Missing value for --mode." >&2 exit 1 fi MODE="$2" MODE_SET=1 shift 2 ;; --variant) if [ $# -lt 2 ]; then echo "Missing value for --variant." >&2 exit 1 fi VARIANT="$2" VARIANT_SET=1 shift 2 ;; --frontend) if [ $# -lt 2 ]; then echo "Missing value for --frontend." >&2 exit 1 fi FRONTEND_ACTION="$2" FRONTEND_SET=1 shift 2 ;; --backend) if [ $# -lt 2 ]; then echo "Missing value for --backend." >&2 exit 1 fi BACKEND_RUNTIME="$2" BACKEND_SET=1 shift 2 ;; --repo) if [ $# -lt 2 ]; then echo "Missing value for --repo." >&2 exit 1 fi REPO_URL="$2" REPO_NAME="${REPO_URL##*/}" REPO_NAME="${REPO_NAME%.git}" if [ "$DIR_SET" -eq 0 ]; then TARGET_DIR="$REPO_NAME" fi shift 2 ;; --dir) if [ $# -lt 2 ]; then echo "Missing value for --dir." >&2 exit 1 fi TARGET_DIR="$2" DIR_SET=1 shift 2 ;; --run) if [ $# -lt 2 ]; then echo "Missing value for --run." >&2 exit 1 fi RUN_AFTER_INSTALL="$2" shift 2 ;; --help|-h) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 1 ;; esac done prompt_choice() { local label="$1" local default="$2" shift 2 local choices=("$@") if [ ! -t 0 ]; then echo "$default" return 0 fi echo "$label" >&2 local i=1 for choice in "${choices[@]}"; do echo " $i) $choice" >&2 i=$((i + 1)) done read -r -p "Select [default: $default]: " input if [ -z "${input}" ]; then echo "$default" return 0 fi if [[ "$input" =~ ^[0-9]+$ ]]; then local index=$((input - 1)) if [ "$index" -ge 0 ] && [ "$index" -lt "${#choices[@]}" ]; then echo "${choices[$index]}" return 0 fi else for choice in "${choices[@]}"; do if [ "$choice" = "$input" ]; then echo "$choice" return 0 fi done fi echo "Invalid choice. Using default: $default" >&2 echo "$default" } if [ "$VARIANT_SET" -eq 0 ]; then VARIANT="$(prompt_choice "Select UI variant:" "web" "web" "island")" fi if [ "$BACKEND_SET" -eq 0 ]; then BACKEND_RUNTIME="$(prompt_choice "Select backend runtime:" "script" "script" "pyinstaller")" fi if [ "$MODE_SET" -eq 0 ]; then MODE="$(prompt_choice "Select app mode:" "tauri" "tauri" "electron" "web")" fi if [ "$MODE" = "island" ]; then MODE="tauri" VARIANT="island" VARIANT_SET=1 fi if [ "$VARIANT" = "island" ] && [ "$MODE" = "web" ]; then echo "Variant 'island' is not supported in web mode. Switching mode to tauri." MODE="tauri" fi if [ "$MODE" = "web" ] && [ "$VARIANT" != "web" ]; then echo "Variant '$VARIANT' is not supported in web mode." >&2 exit 1 fi case "$MODE" in web|tauri|electron) ;; *) echo "Invalid mode: $MODE" >&2 exit 1 ;; esac case "$VARIANT" in web|island) ;; *) echo "Invalid variant: $VARIANT" >&2 exit 1 ;; esac case "$FRONTEND_ACTION" in build|dev) ;; *) echo "Invalid frontend action: $FRONTEND_ACTION" >&2 exit 1 ;; esac case "$BACKEND_RUNTIME" in script|pyinstaller) ;; *) echo "Invalid backend runtime: $BACKEND_RUNTIME" >&2 exit 1 ;; esac if [ "$BACKEND_RUNTIME" = "pyinstaller" ] && [ "$FRONTEND_SET" -eq 0 ]; then FRONTEND_ACTION="build" fi if [ "$FRONTEND_ACTION" = "dev" ] && [ "$BACKEND_RUNTIME" = "pyinstaller" ]; then echo "backend=pyinstaller is only supported with frontend=build." >&2 exit 1 fi if [ "$MODE" = "tauri" ] && [ "$FRONTEND_ACTION" = "build" ] && [ "$VARIANT" = "island" ]; then echo "Island packaging is not supported yet. Switching variant to web for build." VARIANT="web" fi MISSING_DEPS=() MISSING_HINTS=() add_missing() { MISSING_DEPS+=("$1") MISSING_HINTS+=("$2") } OS_TYPE="$(uname -s)" as_root() { if [ "$(id -u)" -eq 0 ]; then "$@" else sudo "$@" fi } ensure_brew() { if command -v brew >/dev/null 2>&1; then return 0 fi if ! command -v curl >/dev/null 2>&1; then echo "curl is required to install Homebrew." >&2 return 1 fi echo "Installing Homebrew..." NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" if [ -x /opt/homebrew/bin/brew ]; then eval "$(/opt/homebrew/bin/brew shellenv)" elif [ -x /usr/local/bin/brew ]; then eval "$(/usr/local/bin/brew shellenv)" fi } install_packages() { if [ "$#" -eq 0 ]; then return 0 fi if [ "$OS_TYPE" = "Darwin" ]; then ensure_brew || return 1 brew install "$@" return $? fi if [ "$OS_TYPE" = "Linux" ]; then if command -v apt-get >/dev/null 2>&1; then as_root apt-get update as_root apt-get install -y "$@" return $? fi if command -v dnf >/dev/null 2>&1; then as_root dnf install -y "$@" return $? fi if command -v yum >/dev/null 2>&1; then as_root yum install -y "$@" return $? fi if command -v pacman >/dev/null 2>&1; then as_root pacman -Sy --noconfirm "$@" return $? fi fi echo "No supported package manager found to install: $*" >&2 return 1 } install_rustup() { if command -v cargo >/dev/null 2>&1; then return 0 fi echo "Installing Rust (rustup)..." if command -v curl >/dev/null 2>&1; then curl -sSf https://sh.rustup.rs | sh -s -- -y elif command -v wget >/dev/null 2>&1; then wget -qO- https://sh.rustup.rs | sh -s -- -y else return 1 fi export PATH="$HOME/.cargo/bin:$PATH" } install_missing_deps() { if [ "${#MISSING_DEPS[@]}" -eq 0 ]; then return 0 fi local packages=() local need_rustup=0 local dep for dep in "${MISSING_DEPS[@]}"; do case "$dep" in python) if [ "$OS_TYPE" = "Darwin" ]; then packages+=("python@3.12") elif [ "$OS_TYPE" = "Linux" ]; then if command -v pacman >/dev/null 2>&1; then packages+=("python" "python-pip") else packages+=("python3" "python3-pip" "python3-venv") fi fi ;; git) packages+=("git") ;; node) if [ "$OS_TYPE" = "Darwin" ]; then packages+=("node") elif [ "$OS_TYPE" = "Linux" ]; then if command -v pacman >/dev/null 2>&1; then packages+=("nodejs" "npm") else packages+=("nodejs" "npm") fi fi ;; cargo) need_rustup=1 ;; curl/wget) if [ "$OS_TYPE" = "Darwin" ]; then packages+=("curl" "wget") elif [ "$OS_TYPE" = "Linux" ]; then packages+=("curl" "wget") fi ;; esac done if [ "${#packages[@]}" -gt 0 ]; then install_packages "${packages[@]}" || return 1 fi if [ "$need_rustup" -eq 1 ]; then install_rustup || return 1 fi } filter_missing_deps() { local remaining_deps=() local remaining_hints=() local dep local hint for i in "${!MISSING_DEPS[@]}"; do dep="${MISSING_DEPS[$i]}" hint="${MISSING_HINTS[$i]}" if [ "$dep" = "python" ]; then if command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1; then continue fi fi if [ "$dep" = "curl/wget" ]; then if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then continue fi fi if ! command -v "$dep" >/dev/null 2>&1; then remaining_deps+=("$dep") remaining_hints+=("$hint") fi done MISSING_DEPS=("${remaining_deps[@]}") MISSING_HINTS=("${remaining_hints[@]}") } find_latest_path() { local base="$1" local pattern="$2" local type="${3:-f}" if [ ! -d "$base" ]; then return 0 fi if [ "$type" = "d" ]; then find "$base" -type d -name "$pattern" -print0 2>/dev/null | xargs -0 ls -td 2>/dev/null | head -n1 else find "$base" -type f -name "$pattern" -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -n1 fi } find_tauri_artifact() { local frontend_dir="$1" local variant="$2" local runtime="$3" local artifact_base="$frontend_dir/dist-artifacts/tauri/$variant/$runtime" local bundle_dir="$frontend_dir/src-tauri/target/release/bundle" if [ "$OS_TYPE" = "Darwin" ]; then local app app="$(find_latest_path "$artifact_base" "*.app" "d")" if [ -n "$app" ]; then echo "$app" return 0 fi app="$(find_latest_path "$bundle_dir/macos" "*.app" "d")" if [ -n "$app" ]; then echo "$app" return 0 fi find_latest_path "$bundle_dir/macos" "*.dmg" "f" || true return 0 fi if [ "$OS_TYPE" = "Linux" ]; then local appimage appimage="$(find_latest_path "$artifact_base" "*.AppImage" "f")" if [ -n "$appimage" ]; then echo "$appimage" return 0 fi appimage="$(find_latest_path "$bundle_dir" "*.AppImage" "f")" if [ -n "$appimage" ]; then echo "$appimage" return 0 fi local deb deb="$(find_latest_path "$artifact_base" "*.deb" "f")" if [ -n "$deb" ]; then echo "$deb" return 0 fi find_latest_path "$bundle_dir" "*.deb" "f" || true return 0 fi find_latest_path "$artifact_base" "*.exe" "f" || true find_latest_path "$bundle_dir" "*.exe" "f" || true find_latest_path "$artifact_base" "*.msi" "f" || true find_latest_path "$bundle_dir" "*.msi" "f" || true } find_electron_artifact() { local frontend_dir="$1" local variant="$2" local runtime="$3" local artifact_base="$frontend_dir/dist-artifacts/electron/$variant/$runtime" if [ "$OS_TYPE" = "Darwin" ]; then local app app="$(find_latest_path "$artifact_base" "*.app" "d")" if [ -n "$app" ]; then echo "$app" return 0 fi find_latest_path "$artifact_base" "*.dmg" "f" || true return 0 fi if [ "$OS_TYPE" = "Linux" ]; then local unpacked unpacked="$(find_latest_path "$artifact_base" "linux-unpacked" "d")" if [ -n "$unpacked" ]; then find "$unpacked" -maxdepth 1 -type f -perm -111 ! -name "chrome-sandbox" ! -name "chrome_crashpad_handler" 2>/dev/null | head -n1 return 0 fi local appimage appimage="$(find_latest_path "$artifact_base" "*.AppImage" "f")" if [ -n "$appimage" ]; then echo "$appimage" return 0 fi find_latest_path "$artifact_base" "*.deb" "f" || true return 0 fi find_latest_path "$artifact_base" "*.exe" "f" || true find_latest_path "$artifact_base" "*.msi" "f" || true } run_artifact() { local artifact="$1" if [ -z "$artifact" ]; then return 1 fi echo "Launching built app: $artifact" if [ "$OS_TYPE" = "Darwin" ]; then open "$artifact" return 0 fi if [ "$OS_TYPE" = "Linux" ]; then if [[ "$artifact" == *.AppImage ]]; then chmod +x "$artifact" "$artifact" & return 0 fi if command -v xdg-open >/dev/null 2>&1; then xdg-open "$artifact" return 0 fi "$artifact" & return 0 fi return 1 } report_missing() { if [ "${#MISSING_DEPS[@]}" -eq 0 ]; then return 0 fi echo "Missing required dependencies:" >&2 for i in "${!MISSING_DEPS[@]}"; do echo "- ${MISSING_DEPS[$i]}: ${MISSING_HINTS[$i]}" >&2 done echo "Install the missing dependencies and retry." >&2 exit 1 } download() { local url="$1" if command -v curl >/dev/null 2>&1; then curl -LsSf "$url" return 0 fi if command -v wget >/dev/null 2>&1; then wget -qO- "$url" return 0 fi echo "Missing required command: curl or wget." >&2 exit 1 } PYTHON_BIN="${PYTHON_BIN:-}" if [ -z "$PYTHON_BIN" ]; then if command -v python >/dev/null 2>&1; then PYTHON_BIN="python" elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN="python3" else add_missing "python" "Python 3.12+ not found. Install Python and retry." fi elif ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then add_missing "python" "Python 3.12+ not found. Install Python and retry." fi if ! command -v git >/dev/null 2>&1; then add_missing "git" "Install Git and retry." fi if ! command -v node >/dev/null 2>&1; then add_missing "node" "Install Node.js 20+ and retry." fi if [ "$MODE" = "tauri" ]; then if ! command -v cargo >/dev/null 2>&1; then add_missing "cargo" "Install Rust (rustup) and retry, or set LIFETRACE_MODE=web." if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then add_missing "curl/wget" "curl or wget is required to install Rust." fi fi fi if ! command -v uv >/dev/null 2>&1; then if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then add_missing "curl/wget" "curl or wget is required to download uv." fi fi if ! command -v pnpm >/dev/null 2>&1; then if ! command -v corepack >/dev/null 2>&1 && ! command -v npm >/dev/null 2>&1; then if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then add_missing "curl/wget" "curl or wget is required to install pnpm." fi fi fi install_missing_deps filter_missing_deps report_missing if [ -z "$PYTHON_BIN" ] || ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then if command -v python >/dev/null 2>&1; then PYTHON_BIN="python" elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN="python3" else echo "Python 3.12+ not found after installation. Reopen your terminal and retry." >&2 exit 1 fi fi if ! command -v uv >/dev/null 2>&1; then echo "Installing uv..." download "https://astral.sh/uv/install.sh" | sh export PATH="$HOME/.local/bin:$PATH" fi if ! command -v pnpm >/dev/null 2>&1; then install_pnpm() { if command -v corepack >/dev/null 2>&1; then if corepack enable >/dev/null 2>&1 && corepack prepare pnpm@latest --activate >/dev/null 2>&1; then command -v pnpm >/dev/null 2>&1 && return 0 fi echo "corepack activation failed. Falling back to pnpm install script." >&2 fi if command -v npm >/dev/null 2>&1; then if npm install -g pnpm >/dev/null 2>&1; then command -v pnpm >/dev/null 2>&1 && return 0 fi echo "npm global install failed. Falling back to pnpm install script." >&2 fi if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then echo "Installing pnpm via install script..." export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}" mkdir -p "$PNPM_HOME" export PATH="$PNPM_HOME:$PATH" if command -v curl >/dev/null 2>&1; then curl -fsSL https://get.pnpm.io/install.sh | sh -s -- else wget -qO- https://get.pnpm.io/install.sh | sh -s -- fi command -v pnpm >/dev/null 2>&1 && return 0 fi return 1 } if ! install_pnpm; then echo "pnpm not found after installation. Reopen your terminal and retry." >&2 exit 1 fi fi REPO_READY=0 DEPS_READY=0 if [ -e "$TARGET_DIR" ] && [ ! -d "$TARGET_DIR/.git" ]; then echo "Target path '$TARGET_DIR' exists and is not a git repo." >&2 echo "Set LIFETRACE_DIR to a new folder and retry." >&2 exit 1 fi if [ -d "$TARGET_DIR/.git" ]; then cd "$TARGET_DIR" if [ -n "$(git status --porcelain)" ]; then echo "Repository has local changes. Commit or stash and retry." >&2 exit 1 fi git fetch --depth 1 "$REPO_URL" "$REF" HEAD_SHA="$(git rev-parse HEAD)" REMOTE_SHA="$(git rev-parse FETCH_HEAD)" if [ "$HEAD_SHA" = "$REMOTE_SHA" ]; then REPO_READY=1 fi else git clone --depth 1 --branch "$REF" "$REPO_URL" "$TARGET_DIR" cd "$TARGET_DIR" fi if [ -d ".venv" ] && [ -d "free-todo-frontend/node_modules" ]; then DEPS_READY=1 fi if [ "$REPO_READY" -eq 0 ] || [ "$DEPS_READY" -eq 0 ]; then if [ -n "$(git status --porcelain)" ]; then echo "Repository has local changes. Commit or stash and retry." >&2 exit 1 fi git fetch --depth 1 "$REPO_URL" "$REF" git checkout -q -B "$REF" FETCH_HEAD uv sync if [ -d ".venv" ] && [ -d "free-todo-frontend/node_modules" ]; then DEPS_READY=1 fi else echo "Repository is up to date. Skipping install steps." fi if [ "$RUN_AFTER_INSTALL" != "1" ]; then echo "Install complete." exit 0 fi case "$MODE" in web) echo "Starting backend..." uv run "$PYTHON_BIN" -m lifetrace.server & BACKEND_PID=$! cleanup() { if kill -0 "$BACKEND_PID" >/dev/null 2>&1; then kill "$BACKEND_PID" >/dev/null 2>&1 || true fi } trap cleanup EXIT cd free-todo-frontend if [ ! -d "node_modules" ]; then pnpm install fi if [ "$FRONTEND_ACTION" = "build" ]; then if [ "$REPO_READY" -eq 1 ] && [ "$DEPS_READY" -eq 1 ] && [ -d ".next" ]; then echo "Next.js build is up to date. Skipping build step." else echo "Building frontend..." pnpm build fi echo "Starting frontend (production)..." pnpm start else echo "Starting frontend (dev)..." WINDOW_MODE="$VARIANT" pnpm dev fi ;; tauri) cd free-todo-frontend if [ ! -d "node_modules" ]; then pnpm install fi if [ "$FRONTEND_ACTION" = "build" ]; then artifact="$(find_tauri_artifact "$(pwd)" "$VARIANT" "$BACKEND_RUNTIME")" if [ -z "$artifact" ] || [ "$REPO_READY" -eq 0 ] || [ "$DEPS_READY" -eq 0 ]; then echo "Building Tauri app ($VARIANT, $BACKEND_RUNTIME)..." pnpm "build:tauri:${VARIANT}:${BACKEND_RUNTIME}:full" artifact="$(find_tauri_artifact "$(pwd)" "$VARIANT" "$BACKEND_RUNTIME")" else echo "Tauri build is up to date. Skipping build step." fi if ! run_artifact "$artifact"; then echo "Build complete. Open the artifact under src-tauri/target/release/bundle/." fi else echo "Starting backend..." uv run "$PYTHON_BIN" -m lifetrace.server & BACKEND_PID=$! cleanup() { if [ -n "${FRONTEND_PID:-}" ] && kill -0 "$FRONTEND_PID" >/dev/null 2>&1; then kill "$FRONTEND_PID" >/dev/null 2>&1 || true fi if kill -0 "$BACKEND_PID" >/dev/null 2>&1; then kill "$BACKEND_PID" >/dev/null 2>&1 || true fi } trap cleanup EXIT echo "Starting frontend dev server..." WINDOW_MODE="$VARIANT" pnpm dev & FRONTEND_PID=$! echo "Starting Tauri app..." pnpm tauri:dev fi ;; electron) cd free-todo-frontend if [ ! -d "node_modules" ]; then pnpm install fi if [ "$FRONTEND_ACTION" = "build" ]; then artifact="$(find_electron_artifact "$(pwd)" "$VARIANT" "$BACKEND_RUNTIME")" if [ -z "$artifact" ] || [ "$REPO_READY" -eq 0 ] || [ "$DEPS_READY" -eq 0 ]; then echo "Building Electron app ($VARIANT, $BACKEND_RUNTIME)..." pnpm "build:electron:${VARIANT}:${BACKEND_RUNTIME}:full:dir" artifact="$(find_electron_artifact "$(pwd)" "$VARIANT" "$BACKEND_RUNTIME")" else echo "Electron build is up to date. Skipping build step." fi if ! run_artifact "$artifact"; then echo "Build complete. Open the artifact under dist-artifacts/electron/." fi else if [ "$VARIANT" = "island" ]; then pnpm electron:dev:island else pnpm electron:dev fi fi ;; esac ================================================ FILE: scripts/link_worktree_deps.ps1 ================================================ param( [Parameter(Mandatory = $true)] [string]$Main, [Parameter(Mandatory = $true)] [string]$Worktree, [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Resolve-FullPath { param([string]$Path) return (Resolve-Path -LiteralPath $Path).Path } function Ensure-Junction { param( [string]$Name, [string]$Source, [string]$Dest ) if (-not (Test-Path -LiteralPath $Source)) { Write-Warning "$Name source not found: $Source (skipped)" return } $destParent = Split-Path -Parent $Dest if (-not (Test-Path -LiteralPath $destParent)) { New-Item -ItemType Directory -Path $destParent | Out-Null } if (Test-Path -LiteralPath $Dest) { $item = Get-Item -LiteralPath $Dest -Force $isReparse = ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 if ($isReparse) { $target = $item.Target if ($target) { $targetFull = Resolve-FullPath $target $sourceFull = Resolve-FullPath $Source if ($targetFull -eq $sourceFull) { Write-Host "$Name already linked: $Dest -> $targetFull" return } } } if (-not $Force) { Write-Warning "$Name destination exists: $Dest (use -Force to replace)" return } Remove-Item -LiteralPath $Dest -Recurse -Force } New-Item -ItemType Junction -Path $Dest -Target $Source | Out-Null Write-Host "Linked ${Name}: $Dest -> $Source" } $mainRoot = Resolve-FullPath $Main $worktreeRoot = Resolve-FullPath $Worktree Ensure-Junction ` -Name "frontend node_modules" ` -Source (Join-Path $mainRoot "free-todo-frontend\node_modules") ` -Dest (Join-Path $worktreeRoot "free-todo-frontend\node_modules") Ensure-Junction ` -Name "python .venv" ` -Source (Join-Path $mainRoot ".venv") ` -Dest (Join-Path $worktreeRoot ".venv") Write-Host "Done." ================================================ FILE: scripts/link_worktree_deps.sh ================================================ #!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: scripts/link_worktree_deps.sh --main --worktree [--force] Example: scripts/link_worktree_deps.sh --main /path/to/LifeTrace \ --worktree /path/to/_worktrees/LifeTrace/chat-tool-ui EOF } main_root="" worktree_root="" force=0 while [[ $# -gt 0 ]]; do case "$1" in --main) main_root="$2" shift 2 ;; --worktree) worktree_root="$2" shift 2 ;; --force) force=1 shift 1 ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage exit 1 ;; esac done if [[ -z "$main_root" || -z "$worktree_root" ]]; then usage exit 1 fi main_root="$(cd "$main_root" && pwd)" worktree_root="$(cd "$worktree_root" && pwd)" link_item() { local name="$1" local src="$2" local dest="$3" if [[ ! -e "$src" ]]; then echo "Skip: $name source not found: $src" return 0 fi mkdir -p "$(dirname "$dest")" if [[ -e "$dest" || -L "$dest" ]]; then if [[ $force -eq 0 ]]; then echo "Skip: $name destination exists: $dest (use --force to replace)" return 0 fi rm -rf "$dest" fi ln -s "$src" "$dest" echo "Linked $name: $dest -> $src" } link_item "frontend node_modules" \ "$main_root/free-todo-frontend/node_modules" \ "$worktree_root/free-todo-frontend/node_modules" link_item "python .venv" \ "$main_root/.venv" \ "$worktree_root/.venv" echo "Done." ================================================ FILE: scripts/link_worktree_deps_here.ps1 ================================================ param( [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Resolve-FullPath { param([string]$Path) return (Resolve-Path -LiteralPath $Path).Path } function Get-RepoRoot { $result = & git rev-parse --show-toplevel 2>$null if (-not $result) { throw "Failed to locate git repo root. Run from inside a git worktree." } return $result.Trim() } function Get-MainWorktree { $lines = & git worktree list --porcelain if (-not $lines) { throw "Failed to read git worktree list." } $paths = @() foreach ($line in $lines) { if ($line -like "worktree *") { $paths += $line.Substring(9).Trim() } } foreach ($path in $paths) { $gitDir = Join-Path $path ".git" if (Test-Path -LiteralPath $gitDir -PathType Container) { return $path } } throw "Could not determine main worktree. Please pass -Main to scripts/link_worktree_deps.ps1." } $worktreeRoot = Resolve-FullPath (Get-RepoRoot) $mainRoot = Resolve-FullPath (Get-MainWorktree) $scriptPath = Join-Path $worktreeRoot "scripts\link_worktree_deps.ps1" if (-not (Test-Path -LiteralPath $scriptPath)) { throw "Missing script: $scriptPath" } if ($Force) { & powershell -ExecutionPolicy Bypass -File $scriptPath -Main $mainRoot -Worktree $worktreeRoot -Force } else { & powershell -ExecutionPolicy Bypass -File $scriptPath -Main $mainRoot -Worktree $worktreeRoot } ================================================ FILE: scripts/link_worktree_deps_here.sh ================================================ #!/usr/bin/env bash set -euo pipefail force=0 if [[ "${1:-}" == "--force" ]]; then force=1 fi repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "${repo_root}" ]]; then echo "Failed to locate git repo root. Run from inside a git worktree." >&2 exit 1 fi main_root="" while IFS= read -r line; do if [[ "$line" == worktree\ * ]]; then path="${line#worktree }" if [[ -d "${path}/.git" ]]; then main_root="${path}" break fi fi done < <(git worktree list --porcelain) if [[ -z "${main_root}" ]]; then echo "Could not determine main worktree. Please pass --main to scripts/link_worktree_deps.sh." >&2 exit 1 fi script_path="${repo_root}/scripts/link_worktree_deps.sh" if [[ ! -f "${script_path}" ]]; then echo "Missing script: ${script_path}" >&2 exit 1 fi if [[ "${force}" -eq 1 ]]; then bash "${script_path}" --main "${main_root}" --worktree "${repo_root}" --force else bash "${script_path}" --main "${main_root}" --worktree "${repo_root}" fi ================================================ FILE: scripts/new_worktree.py ================================================ #!/usr/bin/env python3 import argparse import getpass import os import re import shutil import subprocess # nosec B404 import sys from pathlib import Path def _get_git_path() -> str: git_path = shutil.which("git") if not git_path: raise FileNotFoundError("git executable not found in PATH") return git_path def run_git(root: Path, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: git_path = _get_git_path() return subprocess.run( # nosec B603 [git_path, "-C", str(root), *args], text=True, capture_output=True, check=check, ) def run_link_deps(root: Path, worktree_path: Path, force: bool) -> int: script_dir = root / "scripts" if os.name == "nt": script = script_dir / "link_worktree_deps.ps1" if not script.exists(): print(f"Missing script: {script}", file=sys.stderr) return 1 cmd = [ "powershell", "-ExecutionPolicy", "Bypass", "-File", str(script), "-Main", str(root), "-Worktree", str(worktree_path), ] if force: cmd.append("-Force") else: script = script_dir / "link_worktree_deps.sh" if not script.exists(): print(f"Missing script: {script}", file=sys.stderr) return 1 cmd = [ "bash", str(script), "--main", str(root), "--worktree", str(worktree_path), ] if force: cmd.append("--force") result = subprocess.run(cmd, check=False) # nosec B603 return result.returncode def get_repo_root() -> Path: git_path = _get_git_path() result = subprocess.run( # nosec B603 [git_path, "rev-parse", "--show-toplevel"], text=True, capture_output=True, check=False, ) if result.returncode != 0: print(result.stderr.strip() or "Failed to locate git repo root.", file=sys.stderr) sys.exit(result.returncode or 1) return Path(result.stdout.strip()) def slugify(value: str) -> str: slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-") return slug or "task" def branch_exists(root: Path, branch: str) -> bool: result = run_git(root, ["show-ref", "--verify", f"refs/heads/{branch}"], check=False) return result.returncode == 0 def get_git_user(root: Path) -> str: for key in ("user.name", "user.email"): result = run_git(root, ["config", "--get", key], check=False) value = result.stdout.strip() if not value: continue if key == "user.email": value = value.split("@", 1)[0] return value return getpass.getuser() def summarize_task(task: str, max_words: int = 3) -> str: slug = slugify(task) words = [w for w in slug.split("-") if w] if not words: return "task" return "-".join(words[:max_words]) def normalize_type(value: str) -> str: value = value.strip() if not value: return "chore" return value.lower() def unique_branch_and_path(root: Path, base_branch: str, base_path: Path) -> tuple[str, Path]: suffix = 1 while True: if suffix == 1: branch = base_branch path = base_path else: branch = f"{base_branch}-{suffix}" path = Path(f"{base_path}-{suffix}") if not branch_exists(root, branch) and not path.exists(): return branch, path suffix += 1 def main() -> int: parser = argparse.ArgumentParser(description="Create a git worktree for a task.") parser.add_argument("task", help="Task name (used to build a worktree path and branch).") parser.add_argument( "--type", default="chore", help="Branch type, e.g. Feat/Chore/Fix/Hotfix/Refactor.", ) parser.add_argument( "--user", help="Git username. Defaults to git config user.name (or user.email).", ) parser.add_argument( "--link-deps", action="store_true", help="Link worktree deps (.venv, node_modules) from the main worktree.", ) parser.add_argument( "--force-link", action="store_true", help="Force replace existing linked deps when used with --link-deps.", ) args = parser.parse_args() root = get_repo_root() repo_name = root.name task_summary = summarize_task(args.task) branch_type = normalize_type(args.type) git_user = args.user or get_git_user(root) user_slug = slugify(git_user) base_branch = f"{branch_type}/{user_slug}/{task_summary}" base_dir = root.parent / "_worktrees" / repo_name base_path = base_dir / task_summary branch, worktree_path = unique_branch_and_path(root, base_branch, base_path) base_dir.mkdir(parents=True, exist_ok=True) cmd = ["worktree", "add", "-b", branch, str(worktree_path)] result = run_git(root, cmd, check=False) if result.stdout.strip(): print(result.stdout.strip()) if result.stderr.strip(): print(result.stderr.strip(), file=sys.stderr) if result.returncode != 0: return result.returncode if args.link_deps: link_code = run_link_deps(root, worktree_path, force=args.force_link) if link_code != 0: return link_code print(f"Worktree ready: {worktree_path}") print(f"Branch: {branch}") return 0 if __name__ == "__main__": raise SystemExit(main()) ================================================ FILE: scripts/precommit_clippy.py ================================================ #!/usr/bin/env python3 from __future__ import annotations import os import shutil import subprocess # nosec B404 import sys from pathlib import Path def run() -> int: repo_root = Path(__file__).resolve().parents[1] tauri_dir = repo_root / "free-todo-frontend" / "src-tauri" if not tauri_dir.exists(): print(f"Rust hook skipped: missing {tauri_dir}", file=sys.stderr) return 0 cargo_path = shutil.which("cargo") if not cargo_path: print("cargo not found in PATH. Install Rust and retry.", file=sys.stderr) return 127 env = os.environ.copy() lint_config = tauri_dir / "tauri.lint.json" if lint_config.exists(): env["TAURI_CONFIG"] = lint_config.read_text(encoding="utf-8") env.setdefault("CARGO_TARGET_DIR", str(tauri_dir / "target-clippy")) try: subprocess.run( # nosec B603 [cargo_path, "clippy", "--all-targets", "--all-features", "--", "-D", "warnings"], cwd=tauri_dir, env=env, check=True, ) except subprocess.CalledProcessError as exc: return exc.returncode return 0 if __name__ == "__main__": raise SystemExit(run()) ================================================ FILE: scripts/precommit_rustfmt.py ================================================ #!/usr/bin/env python3 from __future__ import annotations import shutil import subprocess # nosec B404 import sys from pathlib import Path def run() -> int: repo_root = Path(__file__).resolve().parents[1] tauri_dir = repo_root / "free-todo-frontend" / "src-tauri" if not tauri_dir.exists(): print(f"Rust hook skipped: missing {tauri_dir}", file=sys.stderr) return 0 cargo_path = shutil.which("cargo") if not cargo_path: print("cargo not found in PATH. Install Rust and retry.", file=sys.stderr) return 127 try: subprocess.run( # nosec B603 [cargo_path, "fmt", "--all", "--", "--check"], cwd=tauri_dir, check=True, ) except subprocess.CalledProcessError as exc: return exc.returncode return 0 if __name__ == "__main__": raise SystemExit(run()) ================================================ FILE: scripts/setup_hooks_here.ps1 ================================================ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Get-RepoRoot { $result = & git rev-parse --show-toplevel 2>$null if (-not $result) { throw "Failed to locate git repo root. Run from inside a git worktree." } return $result.Trim() } $repoRoot = Get-RepoRoot $hooksDir = Join-Path $repoRoot ".githooks" if (-not (Test-Path -LiteralPath $hooksDir -PathType Container)) { throw "Missing hooks directory: $hooksDir" } & git -C $repoRoot config core.hooksPath .githooks foreach ($hook in @("pre-commit", "post-checkout")) { $hookPath = Join-Path $hooksDir $hook if (-not (Test-Path -LiteralPath $hookPath)) { Write-Warning "Missing hook file: $hookPath" } } Write-Host "Configured core.hooksPath=.githooks for $repoRoot" ================================================ FILE: scripts/setup_hooks_here.sh ================================================ #!/usr/bin/env bash set -euo pipefail repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "${repo_root}" ]]; then echo "Failed to locate git repo root. Run from inside a git worktree." >&2 exit 1 fi hooks_dir="${repo_root}/.githooks" if [[ ! -d "${hooks_dir}" ]]; then echo "Missing hooks directory: ${hooks_dir}" >&2 exit 1 fi git -C "${repo_root}" config core.hooksPath .githooks for hook in pre-commit post-checkout; do if [[ ! -f "${hooks_dir}/${hook}" ]]; then echo "Warning: missing hook file: ${hooks_dir}/${hook}" >&2 fi done if command -v chmod >/dev/null 2>&1; then chmod +x "${hooks_dir}/pre-commit" "${hooks_dir}/post-checkout" 2>/dev/null || true fi echo "Configured core.hooksPath=.githooks for ${repo_root}" ================================================ FILE: tests/conftest.py ================================================ from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) ================================================ FILE: tests/test_icalendar_service.py ================================================ from __future__ import annotations from datetime import datetime, timezone from icalendar import Calendar from lifetrace.schemas.todo import TodoItemType from lifetrace.services.icalendar_service import ICalendarService def test_export_vtodo_fallbacks_due_to_dtstart() -> None: service = ICalendarService() dtstart = datetime(2024, 1, 1, 9, 30, tzinfo=timezone.utc) ics = service.export_todos( [ { "id": 1, "uid": "todo-1", "name": "Test", "item_type": "VTODO", "dtstart": dtstart, } ] ) cal = Calendar.from_ical(ics) component = next(comp for comp in cal.walk() if comp.name == "VTODO") assert component.get("DUE") is not None def test_import_vtodo_duration_keeps_due_none() -> None: service = ICalendarService() ics = "\n".join( [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//LifeTrace//FreeTodo//EN", "BEGIN:VTODO", "UID:todo-2", "SUMMARY:Duration Task", "DTSTART:20240102T090000Z", "DURATION:PT30M", "END:VTODO", "END:VCALENDAR", "", ] ) todos = service.import_todos(ics) assert len(todos) == 1 todo = todos[0] assert todo.item_type == TodoItemType.VTODO assert todo.duration == "PT30M" assert todo.due is None ================================================ FILE: tests/test_todo_serialization.py ================================================ from __future__ import annotations from lifetrace.storage.models import Todo from lifetrace.storage.todo_manager_ical import TodoIcalMixin class StubTodoManager(TodoIcalMixin): def _get_todo_tags(self, session, todo_id: int): return [] def _get_todo_attachments(self, session, todo_id: int): return [] def _set_todo_tags(self, session, todo_id: int, tags): return None def test_todo_to_dict_coerces_is_all_day_none() -> None: manager = StubTodoManager() todo = Todo(name="Test") todo.id = 1 todo.is_all_day = None data = manager._todo_to_dict(None, todo) assert data["is_all_day"] is False def test_todo_to_dict_normalizes_reminder_offsets() -> None: manager = StubTodoManager() todo = Todo(name="Test") todo.id = 1 todo.reminder_offsets = '[30, "15", -5, "bad"]' data = manager._todo_to_dict(None, todo) assert data["reminder_offsets"] == [15, 30] ================================================ FILE: tests/test_todo_service_mapping.py ================================================ from __future__ import annotations from datetime import datetime, timezone import pytest from fastapi import HTTPException from lifetrace.schemas.todo import TodoCreate, TodoUpdate from lifetrace.services.todo_service import TodoService class FakeTodoRepository: def __init__(self) -> None: now = datetime(2024, 1, 1, 8, 0, tzinfo=timezone.utc) self.todo = { "id": 1, "uid": "todo-1", "name": "Test", "item_type": "VTODO", "status": "active", "priority": "none", "created_at": now, "updated_at": now, } self.updated: dict[str, object] | None = None self.created_payload: dict[str, object] | None = None def get_by_id(self, todo_id: int): return self.todo def get_by_uid(self, uid: str): return None def list_todos(self, limit: int, offset: int, status: str | None): return [] def count(self, status: str | None): return 0 def create(self, **kwargs): self.created_payload = kwargs return 1 def update(self, todo_id: int, **kwargs): self.updated = kwargs return True def delete(self, todo_id: int): return True def reorder(self, items): return True def add_attachment( self, *, todo_id: int, file_name: str, file_path: str, file_size: int | None, mime_type: str | None, file_hash: str | None, source: str = "user", ): return None def remove_attachment(self, *, todo_id: int, attachment_id: int): return True def get_attachment(self, attachment_id: int): return None def test_update_todo_dtstart_does_not_touch_due() -> None: repo = FakeTodoRepository() service = TodoService(repo) dtstart = datetime(2024, 1, 1, 10, 0, tzinfo=timezone.utc) service.update_todo(1, TodoUpdate(dtstart=dtstart)) assert repo.updated is not None assert "due" not in repo.updated assert repo.updated["dtstart"] == dtstart assert repo.updated["start_time"] == dtstart def test_update_todo_duration_conflict_raises() -> None: repo = FakeTodoRepository() service = TodoService(repo) due = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc) with pytest.raises(HTTPException): service.update_todo(1, TodoUpdate(duration="PT30M", due=due)) def test_create_todo_duration_conflict_raises() -> None: repo = FakeTodoRepository() service = TodoService(repo) due = datetime(2024, 1, 2, 12, 0, tzinfo=timezone.utc) with pytest.raises(HTTPException): service.create_todo(TodoCreate(name="Test", duration="PT30M", due=due)) def test_update_todo_time_zone_sets_tzid() -> None: repo = FakeTodoRepository() service = TodoService(repo) service.update_todo(1, TodoUpdate(time_zone="Asia/Shanghai")) assert repo.updated is not None assert repo.updated["time_zone"] == "Asia/Shanghai" assert repo.updated["tzid"] == "Asia/Shanghai"