diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a1e330..4d8fe56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,63 @@ jobs: - name: Frontend tests run: pnpm test + - name: Frontend coverage + run: pnpm test:coverage + + monitoring-tests: + name: Monitoring — Typecheck & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/monitoring + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: TypeScript typecheck + run: pnpm typecheck + + - name: Monitoring tests + run: pnpm test + + desktop-tests: + name: Desktop — Typecheck & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/desktop + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: TypeScript typecheck + run: pnpm typecheck + + - name: Desktop tests + run: pnpm test + # ── API tests ─────────────────────────────────────────────────────────────── api-tests: name: API — Tests @@ -101,6 +158,16 @@ jobs: OPENAI_API_KEY: sk-test run: pytest tests/ -v --tb=short --ignore=tests/e2e + - name: Coverage report + env: + DATABASE_URL: postgresql+asyncpg://lyranote:lyranote@localhost:5432/lyranote_test + REDIS_URL: redis://localhost:6379/0 + JWT_SECRET: test-secret-key + STORAGE_BACKEND: local + STORAGE_LOCAL_PATH: /tmp/lyranote-test-storage + OPENAI_API_KEY: sk-test + run: pytest tests/ --cov=app --cov-report=term-missing --ignore=tests/e2e + - name: E2E API tests env: DATABASE_URL: postgresql+asyncpg://lyranote:lyranote@localhost:5432/lyranote_test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c6c054..bbd15a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,42 +9,184 @@ permissions: contents: write jobs: - release: - name: Create GitHub Release + prepare-release: + name: Prepare draft release runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.release.outputs.release_id }} + version: ${{ steps.meta.outputs.version }} + prerelease: ${{ steps.meta.outputs.prerelease }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Extract changelog for this version - id: changelog + - name: Extract release metadata + id: meta + shell: bash run: | VERSION="${GITHUB_REF_NAME#v}" - # Extract the section for this version from CHANGELOG.md + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be a SemVer tag like v1.2.3 or v1.2.3-beta.1" >&2 + exit 1 + fi + NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md) if [ -z "$NOTES" ]; then NOTES="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details." fi - echo "notes<> $GITHUB_OUTPUT - echo "$NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + printf '%s\n' "$NOTES" > release-notes.md + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create or refresh draft release + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + shell: bash + run: | + PRERELEASE="${{ steps.meta.outputs.prerelease }}" + + if RELEASE_ID=$(gh api "repos/${{ github.repository }}/releases/tags/$GITHUB_REF_NAME" --jq '.id' 2>/dev/null); then + jq -n \ + --arg name "LyraNote $GITHUB_REF_NAME" \ + --rawfile body release-notes.md \ + --argjson prerelease "$PRERELEASE" \ + '{ + name: $name, + body: $body, + draft: true, + prerelease: $prerelease + }' > release-payload.json + + gh api \ + --method PATCH \ + "repos/${{ github.repository }}/releases/$RELEASE_ID" \ + --input release-payload.json >/dev/null + else + jq -n \ + --arg tag_name "$GITHUB_REF_NAME" \ + --arg name "LyraNote $GITHUB_REF_NAME" \ + --rawfile body release-notes.md \ + --argjson prerelease "$PRERELEASE" \ + '{ + tag_name: $tag_name, + name: $name, + body: $body, + draft: true, + prerelease: $prerelease + }' > release-payload.json + + RELEASE_ID=$(gh api \ + --method POST \ + "repos/${{ github.repository }}/releases" \ + --input release-payload.json \ + --jq '.id') + fi + echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" + + desktop-macos: + name: Desktop macOS (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + needs: prepare-release + strategy: + fail-fast: false + max-parallel: 1 + matrix: + include: + - arch: aarch64 + runner: macos-15 + target: aarch64-apple-darwin + - arch: x86_64 + runner: macos-15-intel + target: x86_64-apple-darwin + + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: apps/api/requirements*.txt - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - uses: dtolnay/rust-toolchain@stable with: - tag_name: ${{ github.ref_name }} - name: "LyraNote ${{ github.ref_name }}" - body: ${{ steps.changelog.outputs.notes }} - draft: false - prerelease: ${{ contains(github.ref_name, '-') }} - generate_release_notes: true + targets: ${{ matrix.target }} + + - name: Install pnpm dependencies + run: pnpm install --frozen-lockfile + + - name: Install API sidecar dependencies + run: python -m pip install -r apps/api/requirements.txt -r apps/api/requirements-dev.txt + + - name: Sync desktop version from tag + env: + RELEASE_VERSION: ${{ needs.prepare-release.outputs.version }} + run: | + node <<'NODE' + const fs = require("fs") + const version = process.env.RELEASE_VERSION + + function writeJson(path, patch) { + const data = JSON.parse(fs.readFileSync(path, "utf8")) + patch(data) + fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`) + } + + writeJson("apps/desktop/package.json", (data) => { + data.version = version + }) + writeJson("apps/desktop/src-tauri/tauri.conf.json", (data) => { + data.version = version + }) + + const cargoPath = "apps/desktop/src-tauri/Cargo.toml" + const cargo = fs.readFileSync(cargoPath, "utf8") + fs.writeFileSync( + cargoPath, + cargo.replace(/^version = ".*"$/m, `version = "${version}"`), + ) + NODE + + - name: Build bundled API sidecar + run: python apps/api/scripts/build_desktop_sidecar.py --target-triple "${{ matrix.target }}" + + - name: Build and upload Tauri desktop artifacts + uses: tauri-apps/tauri-action@action-v0.6.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + projectPath: apps/desktop + tagName: ${{ github.ref_name }} + releaseDraft: true + prerelease: ${{ needs.prepare-release.outputs.prerelease }} + args: --target ${{ matrix.target }} + assetNamePattern: LyraNote-[version]-macos-[arch]-[bundle][ext] publish-cli: name: Publish @lyranote/cli to npm runs-on: ubuntu-latest - needs: release + needs: prepare-release + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v6 @@ -60,11 +202,34 @@ jobs: run: pnpm install --frozen-lockfile - name: Sync version from git tag - run: | - VERSION="${GITHUB_REF_NAME#v}" - pnpm --filter @lyranote/cli exec npm version "$VERSION" --no-git-tag-version + run: pnpm --filter @lyranote/cli exec npm version "${{ needs.prepare-release.outputs.version }}" --no-git-tag-version + + - name: Skip npm publish when token missing + if: ${{ env.NPM_TOKEN == '' }} + run: echo "NPM_TOKEN is not configured; skipping @lyranote/cli publish." - name: Publish to npm + if: ${{ env.NPM_TOKEN != '' }} run: pnpm --filter @lyranote/cli publish --access public --no-git-checks env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} + + publish-release: + name: Publish GitHub Release + runs-on: ubuntu-latest + needs: + - prepare-release + - desktop-macos + + steps: + - name: Publish draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + shell: bash + run: | + if [[ "${{ needs.prepare-release.outputs.prerelease }}" == "true" ]]; then + gh release edit "$GITHUB_REF_NAME" --draft=false --prerelease + else + gh release edit "$GITHUB_REF_NAME" --draft=false --prerelease=false --latest + fi diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b66410..e168dc6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,18 @@ { - "git.ignoreLimitWarning": true + "git.ignoreLimitWarning": true, + "terminal.integrated.env.osx": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "", + "ANTHROPIC_API_KEY": "" + }, + "terminal.integrated.env.linux": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "", + "ANTHROPIC_API_KEY": "" + }, + "terminal.integrated.env.windows": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "", + "ANTHROPIC_API_KEY": "" + } } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 615f2a7..f6f8a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.0.1] - 2026-04-23 + +### Added +- LyraNote Desktop 的 macOS 打包与 GitHub Release 自动发布流程 +- 桌面端应用内自动更新能力,包括 updater artifacts、签名与 `latest.json` 分发 +- 桌面端运行时与本地 sidecar 的发布级集成,支持 Apple Silicon / Intel 双架构构建 + +### Fixed +- 修复打包后桌面 sidecar 启动不稳定导致的 runtime 卡住问题 +- 修复桌面端聊天流式光标回归与 PR #47 的 CI 阻塞问题 + +### Changed +- Release workflow 调整为 draft-first 流程,并将 CLI npm 发布改为可选步骤 +- 桌面端应用图标与 Web 端保持一致 + +--- + ## [0.3.0] - 2026-03-26 ### Added @@ -55,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Docker Compose dev and production configurations - Bilingual UI (Chinese / English) with next-intl -[Unreleased]: https://github.com/LinMoQC/LyraNote/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/LinMoQC/LyraNote/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/LinMoQC/LyraNote/compare/v1.0.0...v1.0.1 [0.3.0]: https://github.com/LinMoQC/LyraNote/compare/v0.2.0...v0.3.0 [0.1.0]: https://github.com/LinMoQC/LyraNote/releases/tag/v0.1.0 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 9c9f9e9..870fb43 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,15 +1,27 @@ -FROM python:3.12-slim +FROM python:3.12-slim AS builder WORKDIR /app -# Install system deps for asyncpg + BeautifulSoup RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libpq-dev \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt + +FROM python:3.12-slim AS runtime + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +COPY --from=builder /wheels /wheels +RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \ + && rm -rf /wheels COPY . . diff --git a/apps/api/alembic/versions/044_notebook_appearance_settings.py b/apps/api/alembic/versions/044_notebook_appearance_settings.py new file mode 100644 index 0000000..5cbaf29 --- /dev/null +++ b/apps/api/alembic/versions/044_notebook_appearance_settings.py @@ -0,0 +1,25 @@ +"""Add notebook appearance settings + +Revision ID: 044 +Revises: 043 +Create Date: 2026-04-06 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "044" +down_revision = "043" +branch_labels = None +depends_on = None + +json_type = sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql") + + +def upgrade() -> None: + op.add_column("notebooks", sa.Column("appearance_settings", json_type, nullable=True)) + + +def downgrade() -> None: + op.drop_column("notebooks", "appearance_settings") diff --git a/apps/api/alembic/versions/045_observability_trace_hardening.py b/apps/api/alembic/versions/045_observability_trace_hardening.py new file mode 100644 index 0000000..936e513 --- /dev/null +++ b/apps/api/alembic/versions/045_observability_trace_hardening.py @@ -0,0 +1,109 @@ +"""Harden observability trace schema for ops workflows + +Revision ID: 045 +Revises: 044 +Create Date: 2026-04-24 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "045" +down_revision = "044" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "observability_spans", + sa.Column("parent_span_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "observability_spans", + sa.Column("component", sa.String(length=20), nullable=True), + ) + op.add_column( + "observability_spans", + sa.Column("span_kind", sa.String(length=20), nullable=True), + ) + op.create_foreign_key( + "fk_observability_spans_parent_span_id", + "observability_spans", + "observability_spans", + ["parent_span_id"], + ["id"], + ondelete="SET NULL", + ) + + op.create_index("ix_observability_runs_started_at", "observability_runs", ["started_at"]) + op.create_index( + "ix_observability_runs_run_type_started_at", + "observability_runs", + ["run_type", "started_at"], + ) + op.create_index( + "ix_observability_runs_status_started_at", + "observability_runs", + ["status", "started_at"], + ) + op.create_index("ix_observability_runs_user_id", "observability_runs", ["user_id"]) + op.create_index( + "ix_observability_runs_conversation_id", + "observability_runs", + ["conversation_id"], + ) + op.create_index( + "ix_observability_runs_generation_id", + "observability_runs", + ["generation_id"], + ) + op.create_index("ix_observability_runs_task_id", "observability_runs", ["task_id"]) + op.create_index( + "ix_observability_runs_task_run_id", + "observability_runs", + ["task_run_id"], + ) + op.create_index( + "ix_observability_runs_notebook_id", + "observability_runs", + ["notebook_id"], + ) + + op.create_index( + "ix_observability_spans_parent_span_id", + "observability_spans", + ["parent_span_id"], + ) + op.create_index("ix_observability_spans_started_at", "observability_spans", ["started_at"]) + op.create_index( + "ix_observability_spans_trace_id_started_at", + "observability_spans", + ["trace_id", "started_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_observability_spans_trace_id_started_at", table_name="observability_spans") + op.drop_index("ix_observability_spans_started_at", table_name="observability_spans") + op.drop_index("ix_observability_spans_parent_span_id", table_name="observability_spans") + + op.drop_index("ix_observability_runs_notebook_id", table_name="observability_runs") + op.drop_index("ix_observability_runs_task_run_id", table_name="observability_runs") + op.drop_index("ix_observability_runs_task_id", table_name="observability_runs") + op.drop_index("ix_observability_runs_generation_id", table_name="observability_runs") + op.drop_index("ix_observability_runs_conversation_id", table_name="observability_runs") + op.drop_index("ix_observability_runs_user_id", table_name="observability_runs") + op.drop_index("ix_observability_runs_status_started_at", table_name="observability_runs") + op.drop_index("ix_observability_runs_run_type_started_at", table_name="observability_runs") + op.drop_index("ix_observability_runs_started_at", table_name="observability_runs") + + op.drop_constraint( + "fk_observability_spans_parent_span_id", + "observability_spans", + type_="foreignkey", + ) + op.drop_column("observability_spans", "span_kind") + op.drop_column("observability_spans", "component") + op.drop_column("observability_spans", "parent_span_id") diff --git a/apps/api/app/agents/chat/__init__.py b/apps/api/app/agents/chat/__init__.py index fce2d2d..517f413 100644 --- a/apps/api/app/agents/chat/__init__.py +++ b/apps/api/app/agents/chat/__init__.py @@ -1,6 +1,7 @@ """Chat generation background task helpers.""" from .task_manager import ( + cancel_message_generation_task, GenerationBuffer, get_generation_buffer, get_generation_task, @@ -10,6 +11,7 @@ ) __all__ = [ + "cancel_message_generation_task", "GenerationBuffer", "get_generation_buffer", "get_generation_task", diff --git a/apps/api/app/agents/chat/task_manager.py b/apps/api/app/agents/chat/task_manager.py index 543bdb3..2397860 100644 --- a/apps/api/app/agents/chat/task_manager.py +++ b/apps/api/app/agents/chat/task_manager.py @@ -94,6 +94,13 @@ def get_generation_task(generation_id: str) -> asyncio.Task[None] | None: return _tasks.get(generation_id) +def cancel_message_generation_task(generation_id: str) -> asyncio.Task[None] | None: + task = _tasks.get(generation_id) + if task is not None and not task.done(): + task.cancel() + return task + + async def load_generation_events(generation_id: UUID, from_index: int = 0) -> list[dict]: async with AsyncSessionLocal() as db: result = await db.execute( @@ -118,12 +125,14 @@ async def run_message_generation( trace_id: str | None, ) -> None: """Run the normal chat agent in the background and persist streaming progress.""" - from app.agents.core.react_agent import run_agent - from app.agents.memory import get_notebook_summary + from app.agents.core.react_agent import ( + classify_agent_execution_route, + run_agent, + ) from app.services.conversation_service import ( ConversationService, _extract_genui_from_content, - _load_user_memories_safely, + _load_prompt_context_safely, ) buf = GenerationBuffer() @@ -131,6 +140,15 @@ async def run_message_generation( trace_token = None run_token = None run = None + full_content: list[str] = [] + full_reasoning: list[str] = [] + citations: list[dict] = [] + agent_steps: list[dict] = [] + speed_metrics: dict | None = None + mind_map_data: dict | None = None + diagram_data: dict | None = None + mcp_result_data: dict | None = None + ui_elements_data: list[dict] = [] try: generation_uuid = UUID(generation_id) @@ -171,15 +189,21 @@ async def run_message_generation( async with traced_span(db, "chat.history_load", run=run): history = await service._load_history(conversation.id) - notebook_summary = ( - await get_notebook_summary(conversation.notebook_id, db) - if conversation.notebook_id else None + execution_route = classify_agent_execution_route( + query=content, + attachment_ids=attachment_ids, + tool_hint=tool_hint, ) + scene = "research" if execution_route.mode == "multi" else "chat" async with traced_span(db, "chat.memory_load", run=run): - user_memories = await _load_user_memories_safely( + prompt_context = await _load_prompt_context_safely( db, user_id, current_query=content, + scene=scene, + notebook_id=conversation.notebook_id, + conversation_id=conversation.id, + include_portrait=execution_route.mode == "multi", ) await update_observability_run( db, @@ -187,20 +211,12 @@ async def run_message_generation( metadata={ "model": generation.model, "history_turn_count": len(history), - "memory_count": len(user_memories), + "memory_count": len(prompt_context.all_memories), "attachment_count": len(attachment_ids or []), + "scene": scene, }, ) - full_content: list[str] = [] - full_reasoning: list[str] = [] - citations: list[dict] = [] - agent_steps: list[dict] = [] - speed_metrics: dict | None = None - mind_map_data: dict | None = None - diagram_data: dict | None = None - mcp_result_data: dict | None = None - ui_elements_data: list[dict] = [] next_event_index = generation.last_event_index + 1 token_since_flush = 0 last_flush_at = time.monotonic() @@ -250,8 +266,7 @@ async def _flush_progress(*, force: bool = False) -> None: user_id=user_id, history=history, db=db, - user_memories=user_memories, - notebook_summary=notebook_summary, + prompt_context=prompt_context, global_search=True if conversation.notebook_id is None else global_search, tool_hint=tool_hint, attachment_ids=attachment_ids, @@ -399,7 +414,7 @@ async def _flush_progress(*, force: bool = False) -> None: metadata={ **detail_summary, "tool_call_count": tool_call_count, - "scene": "research", + "scene": scene, "citations_count": len(citations), "execution_path": execution_path, "policy_trace": policy_trace, @@ -415,11 +430,82 @@ async def _flush_progress(*, force: bool = False) -> None: ) await db.commit() - service._dispatch_post_chat_tasks(conversation.id, "research", user_memories) + service._dispatch_post_chat_tasks( + conversation.id, + scene, + prompt_context.all_memories, + ) break await _persist_event(event) + except asyncio.CancelledError: + logger.info("Message generation %s cancelled", generation_id) + try: + async with AsyncSessionLocal() as db: + generation_uuid = UUID(generation_id) + generation = await db.get(MessageGeneration, generation_uuid) + if generation is not None and generation.status not in {"done", "error", "cancelled"}: + assistant_message = await db.get(Message, generation.assistant_message_id) + content_text = "".join(full_content) + reasoning_text = "".join(full_reasoning).strip() or None + has_visible_output = bool( + content_text + or reasoning_text + or citations + or agent_steps + or speed_metrics + or mind_map_data + or diagram_data + or mcp_result_data + or ui_elements_data + ) + + generation.status = "cancelled" + generation.completed_at = datetime.now(timezone.utc) + generation.error_message = None + + if assistant_message is not None: + if has_visible_output: + assistant_message.content = content_text + assistant_message.reasoning = reasoning_text + assistant_message.citations = citations or None + assistant_message.agent_steps = _snapshot_list(agent_steps) + assistant_message.speed = speed_metrics + assistant_message.mind_map = mind_map_data + assistant_message.diagram = diagram_data + assistant_message.mcp_result = mcp_result_data + assistant_message.ui_elements = _snapshot_list(ui_elements_data) + assistant_message.status = "completed" + else: + await db.delete(assistant_message) + + result = await db.execute( + select(ObservabilityRun) + .where(ObservabilityRun.generation_id == generation_uuid) + .order_by(ObservabilityRun.started_at.desc()) + .limit(1) + ) + run = result.scalar_one_or_none() + if run is not None: + detail_summary = await summarize_run_details(db, run.id) + await finish_observability_run( + db, + run, + status="cancelled", + metadata={ + **detail_summary, + "query_snapshot": build_text_snapshot(content), + "final_answer_snapshot": build_text_snapshot(content_text), + "reasoning_snapshot": build_text_snapshot(reasoning_text or ""), + }, + error_message=None, + ) + await db.commit() + except Exception: + logger.exception("Failed to persist cancelled state for generation %s", generation_id) + return + except Exception as exc: logger.exception("Message generation %s failed", generation_id) error_event = { diff --git a/apps/api/app/agents/core/engine.py b/apps/api/app/agents/core/engine.py index 59ffae2..b275f74 100644 --- a/apps/api/app/agents/core/engine.py +++ b/apps/api/app/agents/core/engine.py @@ -19,7 +19,11 @@ from app.agents.core.brain import AgentBrain from app.agents.core.hooks import PostToolHook, default_post_tool_hooks from app.agents.core.llm_backend import DefaultLLMBackend, LLMBackend -from app.agents.core.retry import classify_llm_error, max_retries_for, sleep_before_retry +from app.agents.core.retry import ( + classify_llm_error, + max_retries_for, + sleep_before_retry, +) from app.agents.core.instructions import ( CallLLMInstruction, CallRAGInstruction, @@ -90,8 +94,8 @@ def _split_mcp_html(result: str) -> tuple[str, str | None]: return result, None end = result.find(_MCP_HTML_END, start) if end == -1: - return result[:start].rstrip(), result[start + len(_MCP_HTML_START):] - html = result[start + len(_MCP_HTML_START):end] + return result[:start].rstrip(), result[start + len(_MCP_HTML_START) :] + html = result[start + len(_MCP_HTML_START) : end] text = result[:start].rstrip() return text, html @@ -114,6 +118,7 @@ def _try_parse_mcp_json(tool_name: str, result: str) -> dict | None: text_part, html = _split_mcp_html(result) if html is not None: import json as _json + payload: dict = {"tool": tool_name, "html_content": html.strip()} # Optionally attach any JSON from the text part stripped_text = text_part.strip() @@ -130,6 +135,7 @@ def _try_parse_mcp_json(tool_name: str, result: str) -> dict | None: return None try: import json + data = json.loads(stripped) except (json.JSONDecodeError, ValueError): return None @@ -207,7 +213,9 @@ def __init__( # None → use the default registered hooks. Pass an explicit list to override # (useful for testing or extending with custom post-processing). self._post_tool_hooks: list[PostToolHook] = ( - post_tool_hooks if post_tool_hooks is not None else default_post_tool_hooks() + post_tool_hooks + if post_tool_hooks is not None + else default_post_tool_hooks() ) async def run(self, state: AgentState) -> AsyncGenerator[dict, None]: @@ -261,20 +269,25 @@ async def _exec_call_llm(self, state: AgentState) -> AsyncGenerator[dict, None]: if key.endswith("::{}") } tool_schemas = ( - [s for s in self.tool_schemas - if s.get("function", {}).get("name") not in cached_no_arg_tools] + [ + s + for s in self.tool_schemas + if s.get("function", {}).get("name") not in cached_no_arg_tools + ] if cached_no_arg_tools else self.tool_schemas ) if state.step_count >= state.max_steps - 2 and state.step_count > 0: - state.messages.append({ - "role": "user", - "content": ( - f"[系统提示] 你已使用 {state.step_count}/{state.max_steps} 步。" - "请尽快整合已有信息回答用户,避免不必要的额外工具调用。" - ), - }) + state.messages.append( + { + "role": "user", + "content": ( + f"[系统提示] 你已使用 {state.step_count}/{state.max_steps} 步。" + "请尽快整合已有信息回答用户,避免不必要的额外工具调用。" + ), + } + ) t0 = time.monotonic() llm_started_at = utcnow() @@ -316,7 +329,10 @@ async def _exec_call_llm(self, state: AgentState) -> AsyncGenerator[dict, None]: elif chunk["type"] == "tool_calls": got_tool_calls = True if output_parts: - yield {"type": "thought", "content": "".join(output_parts)} + yield { + "type": "thought", + "content": "".join(output_parts), + } state.messages.append(chunk["raw_assistant"]) state.pending_tool_calls = chunk["calls"] break # success — exit retry loop @@ -361,9 +377,17 @@ async def _exec_call_llm(self, state: AgentState) -> AsyncGenerator[dict, None]: finished_at=utcnow(), duration_ms=int(elapsed * 1000), ) - logger.error("LLM call failed (class=%s, attempts=%d): %s", error_class, _llm_attempt + 1, exc) + logger.error( + "LLM call failed (class=%s, attempts=%d): %s", + error_class, + _llm_attempt + 1, + exc, + ) state.phase = "error" - yield {"type": "error", "content": f"AI 服务暂时不可用,请稍后重试。({type(exc).__name__})"} + yield { + "type": "error", + "content": f"AI 服务暂时不可用,请稍后重试。({type(exc).__name__})", + } yield {"type": "done"} return @@ -376,7 +400,10 @@ async def _exec_call_llm(self, state: AgentState) -> AsyncGenerator[dict, None]: prompt=state.messages, response={"content": final_output, "reasoning": final_reasoning}, finish_reason="tool_calls" if got_tool_calls else "stop", - metadata={"message_count": len(state.messages), "step_count": state.step_count}, + metadata={ + "message_count": len(state.messages), + "step_count": state.step_count, + }, input_tokens=estimate_message_tokens(state.messages), output_tokens=estimate_tokens(final_output), reasoning_tokens=estimate_tokens(final_reasoning), @@ -400,7 +427,11 @@ async def _exec_call_llm(self, state: AgentState) -> AsyncGenerator[dict, None]: else: # Model answered directly — tokens already streamed, emit closing events. # Prefer actual token count from API usage; fall back to text-length estimate. - output_tokens = api_output_tokens if api_output_tokens is not None else estimate_tokens(final_output) + output_tokens = ( + api_output_tokens + if api_output_tokens is not None + else estimate_tokens(final_output) + ) tps = output_tokens / elapsed if elapsed > 0 else 0 yield { "type": "speed", @@ -423,9 +454,15 @@ async def _exec_call_tools( # already in the message history from a previous step. is_cached = state.is_tool_cached(tc["name"], tc.get("arguments", {})) if not is_cached: - yield {"type": "tool_call", "tool": tc["name"], "input": tc["arguments"]} + yield { + "type": "tool_call", + "tool": tc["name"], + "input": tc["arguments"], + } else: - state.add_policy_trace("tool_cache_hit", "duplicate_tool_call_blocked", tc["name"]) + state.add_policy_trace( + "tool_cache_hit", "duplicate_tool_call_blocked", tc["name"] + ) yield { "type": "agent_trace", "event": "tool_cache_hit", @@ -441,7 +478,10 @@ async def _resolve(tc: dict) -> dict[str, Any]: started = time.monotonic() cache_key = state.tool_cache_key(tc["name"], args) if cache_key in state.tool_result_cache: - logger.debug("Tool '%s' already called with same args — returning cached result", tc["name"]) + logger.debug( + "Tool '%s' already called with same args — returning cached result", + tc["name"], + ) finished_at = utcnow() return { "result": state.tool_result_cache[cache_key], @@ -455,7 +495,9 @@ async def _resolve(tc: dict) -> dict[str, Any]: if state.tool_failure_counts.get(tc["name"], 0) >= 2: skip_result = f"[系统已阻止重复调用工具 {tc['name']}:此前已连续失败 2 次,请改用其他工具或直接回答]" state.tool_result_cache[cache_key] = skip_result - state.add_policy_trace("tool_blocked", "tool_failure_attempt_limit", tc["name"]) + state.add_policy_trace( + "tool_blocked", "tool_failure_attempt_limit", tc["name"] + ) finished_at = utcnow() return { "result": skip_result, @@ -467,19 +509,30 @@ async def _resolve(tc: dict) -> dict[str, Any]: "duration_ms": int((time.monotonic() - started) * 1000), } try: - state.tool_call_counts[tc["name"]] = state.tool_call_counts.get(tc["name"], 0) + 1 + state.tool_call_counts[tc["name"]] = ( + state.tool_call_counts.get(tc["name"], 0) + 1 + ) result = await execute_tool(tc, self.tool_ctx) except BaseException as exc: + if isinstance(exc, asyncio.CancelledError): + raise from app.mcp.client import _exc_msg + msg = _exc_msg(exc) - logger.error("Tool '%s' raised error: %s", tc["name"], msg, exc_info=True) + logger.error( + "Tool '%s' raised error: %s", tc["name"], msg, exc_info=True + ) result = f"[工具 {tc['name']} 调用失败: {msg}]" - state.tool_failure_counts[tc["name"]] = state.tool_failure_counts.get(tc["name"], 0) + 1 + state.tool_failure_counts[tc["name"]] = ( + state.tool_failure_counts.get(tc["name"], 0) + 1 + ) status = "error" error_message = msg else: if result.strip().startswith("[工具") and "调用失败" in result: - state.tool_failure_counts[tc["name"]] = state.tool_failure_counts.get(tc["name"], 0) + 1 + state.tool_failure_counts[tc["name"]] = ( + state.tool_failure_counts.get(tc["name"], 0) + 1 + ) status = "error" error_message = result[:2000] else: @@ -513,7 +566,8 @@ async def _resolve(tc: dict) -> dict[str, Any]: read_results = ( await asyncio.gather(*[_resolve(tc) for tc in read_calls]) - if read_calls else [] + if read_calls + else [] ) write_results: list[dict] = [] for tc in write_calls: @@ -556,8 +610,13 @@ async def _resolve(tc: dict) -> dict[str, Any]: _DEFAULT_MICRO_COMPACT_LIMIT = 3000 try: from app.skills.registry import skill_registry as _sr + _skill = _sr.resolve(tc["name"]) - _limit = (_skill.meta.max_result_chars if _skill is not None else _DEFAULT_MICRO_COMPACT_LIMIT) + _limit = ( + _skill.meta.max_result_chars + if _skill is not None + else _DEFAULT_MICRO_COMPACT_LIMIT + ) except Exception: _limit = _DEFAULT_MICRO_COMPACT_LIMIT # max_result_chars=0 means "never truncate" (e.g. read_skill_guide body) @@ -565,11 +624,13 @@ async def _resolve(tc: dict) -> dict[str, Any]: msg_result = result[:_limit] + "\n…[内容已截断,完整结果已存入上下文]" else: msg_result = result - state.messages.append({ - "role": "tool", - "tool_call_id": tc["id"], - "content": msg_result, - }) + state.messages.append( + { + "role": "tool", + "tool_call_id": tc["id"], + "content": msg_result, + } + ) followup_tool = _extract_followup_tool_hint(result) result_count = None if tc["name"] in {"search_notebook_knowledge", "web_search"}: @@ -595,14 +656,18 @@ async def _resolve(tc: dict) -> dict[str, Any]: ) if followup_tool: state.recommended_next_tool = followup_tool - state.add_policy_trace("mcp_followup", "tool_result_requested_followup", followup_tool) - state.messages.append({ - "role": "user", - "content": ( - f"[系统提示] 上一步工具结果明确要求下一步优先使用 `{followup_tool}`。" - "不要重复调用刚才的工具。" - ), - }) + state.add_policy_trace( + "mcp_followup", "tool_result_requested_followup", followup_tool + ) + state.messages.append( + { + "role": "user", + "content": ( + f"[系统提示] 上一步工具结果明确要求下一步优先使用 `{followup_tool}`。" + "不要重复调用刚才的工具。" + ), + } + ) yield { "type": "agent_trace", "event": "mcp_followup", @@ -624,13 +689,15 @@ async def _resolve(tc: dict) -> dict[str, Any]: # ignore in-result instructions (e.g. "Do NOT call me again"). if pre_cached: names = ", ".join(f"`{n}`" for n in sorted(pre_cached)) - state.messages.append({ - "role": "user", - "content": ( - f"[系统提示] 工具 {names} 已经调用过,其结果已在上方对话中。" - f"请不要再次调用这些工具。根据已有结果,直接调用下一步工具或回答用户。" - ), - }) + state.messages.append( + { + "role": "user", + "content": ( + f"[系统提示] 工具 {names} 已经调用过,其结果已在上方对话中。" + f"请不要再次调用这些工具。根据已有结果,直接调用下一步工具或回答用户。" + ), + } + ) yield { "type": "agent_trace", "event": "tool_cache_guard", @@ -663,7 +730,7 @@ async def _exec_compress_context( # re-derive it from state: if snip_count < _MAX_SNIP_PASSES and tokens are in # the snip band, this is a snip pass; otherwise it is a summarize pass. # In practice the Brain already decided the mode — we just need to honour it. - _SNIP_DROP_COUNT = 4 # messages to drop per snip pass + _SNIP_DROP_COUNT = 4 # messages to drop per snip pass _SNIP_THRESHOLD = 4000 # must match brain.SNIP_TOKEN_THRESHOLD msgs = state.messages @@ -697,7 +764,8 @@ async def _exec_compress_context( # Determine which layer to run based on current compression state. use_snip = ( state.snip_count < 2 - and state.estimate_tokens() <= 8000 # haven't breached LLM compress threshold + and state.estimate_tokens() + <= 8000 # haven't breached LLM compress threshold ) # ── Layer 2: snipCompact ──────────────────────────────────────────── @@ -732,8 +800,7 @@ async def _exec_compress_context( } middle_text = "\n".join( - f"[{m.get('role', '?')}] {str(m.get('content', ''))[:500]}" - for m in middle + f"[{m.get('role', '?')}] {str(m.get('content', ''))[:500]}" for m in middle ) compress_prompt = [ { @@ -747,7 +814,9 @@ async def _exec_compress_context( llm_started_at = utcnow() llm_started = time.monotonic() try: - summary = await self._llm.chat(compress_prompt, _get_utility_model(), 0, 300) + summary = await self._llm.chat( + compress_prompt, _get_utility_model(), 0, 300 + ) except Exception: logger.warning("Context compression failed, continuing without compression") await record_completed_llm_call( @@ -862,9 +931,14 @@ async def _exec_request_approval( state.pending_tool_calls = instruction.tool_calls state.phase = "llm_result" else: - yield {"type": "token", "content": "\n\n> 工具调用已被拒绝,AI 将直接回答。"} + yield { + "type": "token", + "content": "\n\n> 工具调用已被拒绝,AI 将直接回答。", + } state.pending_tool_calls = [] - state.phase = "llm_result" # empty pending → Brain returns StreamAnswerInstruction + state.phase = ( + "llm_result" # empty pending → Brain returns StreamAnswerInstruction + ) async def _exec_call_rag( self, instruction: CallRAGInstruction, state: AgentState @@ -878,8 +952,18 @@ async def _time_call(awaitable): try: result = await awaitable except Exception as exc: - return exc, started_at, utcnow(), int((time.monotonic() - started) * 1000) - return result, started_at, utcnow(), int((time.monotonic() - started) * 1000) + return ( + exc, + started_at, + utcnow(), + int((time.monotonic() - started) * 1000), + ) + return ( + result, + started_at, + utcnow(), + int((time.monotonic() - started) * 1000), + ) rag_task = _time_call( retrieve_chunks( @@ -913,7 +997,11 @@ async def _time_call(awaitable): "global_search": self.tool_ctx.global_search, "query_snapshot": build_text_snapshot(instruction.query), "hit_count": 0 if isinstance(chunks, Exception) else len(chunks), - "source_count": 0 if isinstance(chunks, Exception) else len({c.get("source_id") for c in chunks if c.get("source_id")}), + "source_count": ( + 0 + if isinstance(chunks, Exception) + else len({c.get("source_id") for c in chunks if c.get("source_id")}) + ), }, error_message=str(chunks) if isinstance(chunks, Exception) else None, started_at=rag_started_at, @@ -927,7 +1015,9 @@ async def _time_call(awaitable): metadata={ "global_search": self.tool_ctx.global_search, "query_snapshot": build_text_snapshot(instruction.query), - "output_snapshot": build_text_snapshot("" if isinstance(graph_ctx, Exception) else graph_ctx), + "output_snapshot": build_text_snapshot( + "" if isinstance(graph_ctx, Exception) else graph_ctx + ), }, error_message=str(graph_ctx) if isinstance(graph_ctx, Exception) else None, started_at=graph_started_at, @@ -957,7 +1047,9 @@ async def _time_call(awaitable): tool_results = [c["content"] for c in chunks] self.tool_ctx.collected_citations = state.citations state.needs_verification = True - state.verification_reason = "本轮依赖检索资料,请在回答前核对引用编号、资料内容与最终结论是否一致。" + state.verification_reason = ( + "本轮依赖检索资料,请在回答前核对引用编号、资料内容与最终结论是否一致。" + ) else: tool_results = [] @@ -975,10 +1067,11 @@ async def _exec_verify_result( ) -> AsyncGenerator[dict, None]: """Insert a lightweight verification reminder into the conversation.""" checklist = [ - "在给出最终回答前,请先自检:", + f"[系统提示] 请在回答前快速自检,然后直接回答用户的原始问题「{state.query}」:", "- 若引用了资料,检查每个关键结论是否都有对应来源支撑;", "- 若工具已经生成了结构化结果,不要忽略工具输出后另起一套结论;", "- 若依据不足,要明确说明信息缺口,不要假装已经验证完成。", + "注意:这是系统自检指令,不是用户消息。请勿对此进行回复或确认,直接回答用户问题。", ] if instruction.reason: checklist.insert(1, f"- 额外提醒:{instruction.reason}") @@ -1008,7 +1101,9 @@ async def _exec_clarify( from app.agents.core.policy import build_clarification_prompt prompt = build_clarification_prompt(state.query, state.active_scene) - state.add_policy_trace("clarify", "query_is_too_ambiguous", instruction.reason or prompt) + state.add_policy_trace( + "clarify", "query_is_too_ambiguous", instruction.reason or prompt + ) yield { "type": "agent_trace", "event": "clarify", @@ -1020,7 +1115,9 @@ async def _exec_clarify( yield {"type": "done"} state.phase = "done" - async def _exec_stream_answer(self, state: AgentState) -> AsyncGenerator[dict, None]: + async def _exec_stream_answer( + self, state: AgentState + ) -> AsyncGenerator[dict, None]: """Text-only streaming answer — used only for the has_tools=False RAG path and after terminal tools. Normal tool-use responses are handled directly by _exec_call_llm.""" @@ -1039,24 +1136,38 @@ async def _exec_stream_answer(self, state: AgentState) -> AsyncGenerator[dict, N max_total_chars=state.context_budget_chars, ) last_user = next( - (i for i in range(len(clean) - 1, -1, -1) if clean[i].get("role") == "user"), + ( + i + for i in range(len(clean) - 1, -1, -1) + if clean[i].get("role") == "user" + ), -1, ) if last_user >= 0: - clean.insert(last_user, { - "role": "user", - "content": f"以下是检索到的参考资料:\n\n{combined}", - }) - clean.insert(last_user + 1, { - "role": "assistant", - "content": "好的,我已阅读参考资料,请继续。", - }) + clean.insert( + last_user, + { + "role": "user", + "content": f"以下是检索到的参考资料:\n\n{combined}", + }, + ) + clean.insert( + last_user + 1, + { + "role": "assistant", + "content": "好的,我已阅读参考资料,请继续。", + }, + ) # After compression + filtering, the original query may have been lost. # Ensure it is always the last user message so the AI knows what to answer. if state.query: last_msg = clean[-1] if clean else None - if not last_msg or last_msg.get("role") != "user" or last_msg.get("content") != state.query: + if ( + not last_msg + or last_msg.get("role") != "user" + or last_msg.get("content") != state.query + ): clean.append({"role": "user", "content": state.query}) t0 = time.monotonic() @@ -1072,7 +1183,9 @@ async def _exec_stream_answer(self, state: AgentState) -> AsyncGenerator[dict, N "chat.llm.stream", metadata={"thinking_enabled": self.thinking_enabled}, ): - async for chunk in self._llm.chat_stream(clean, thinking_enabled=self.thinking_enabled): + async for chunk in self._llm.chat_stream( + clean, thinking_enabled=self.thinking_enabled + ): if chunk.get("type") == "token": token_count += 1 output_parts.append(str(chunk.get("content") or "")) @@ -1103,7 +1216,10 @@ async def _exec_stream_answer(self, state: AgentState) -> AsyncGenerator[dict, N duration_ms=int((time.monotonic() - t0) * 1000), ) logger.error("Stream answer failed: %s", exc) - yield {"type": "error", "content": f"AI 服务暂时不可用,请稍后重试。({type(exc).__name__})"} + yield { + "type": "error", + "content": f"AI 服务暂时不可用,请稍后重试。({type(exc).__name__})", + } yield {"type": "done"} state.phase = "error" return diff --git a/apps/api/app/agents/core/policy.py b/apps/api/app/agents/core/policy.py index e81f03d..7acc310 100644 --- a/apps/api/app/agents/core/policy.py +++ b/apps/api/app/agents/core/policy.py @@ -9,6 +9,7 @@ from __future__ import annotations _SCENE_CONTEXT_BUDGETS = { + "chat": 5000, "research": 8000, "writing": 5000, "learning": 5500, @@ -17,7 +18,7 @@ def context_budget_for_scene(scene: str) -> int: - return _SCENE_CONTEXT_BUDGETS.get(scene, _SCENE_CONTEXT_BUDGETS["research"]) + return _SCENE_CONTEXT_BUDGETS.get(scene, _SCENE_CONTEXT_BUDGETS["chat"]) def build_clarification_prompt(query: str, scene: str) -> str: diff --git a/apps/api/app/agents/core/react_agent.py b/apps/api/app/agents/core/react_agent.py index f8d9074..c30f313 100644 --- a/apps/api/app/agents/core/react_agent.py +++ b/apps/api/app/agents/core/react_agent.py @@ -27,6 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession +from app.agents.memory import PromptContextBundle, build_prompt_context_bundle from app.agents.core.attachment_text import extract_attachment_text from app.agents.core.brain import AgentBrain from app.agents.core.engine import AgentEngine @@ -120,8 +121,7 @@ async def run_agent( user_id: UUID, history: list[dict], db: AsyncSession, - user_memories: list[dict] | None = None, - notebook_summary: dict | None = None, + prompt_context: PromptContextBundle | None = None, global_search: bool = False, tool_hint: str | None = None, attachment_ids: list[str] | None = None, @@ -161,8 +161,7 @@ async def run_agent( user_id=user_id, history=history, db=db, - user_memories=user_memories, - notebook_summary=notebook_summary, + prompt_context=prompt_context, global_search=global_search, ): yield event @@ -179,8 +178,7 @@ async def run_agent( user_id=user_id, history=history, db=db, - user_memories=user_memories, - notebook_summary=notebook_summary, + prompt_context=prompt_context, global_search=global_search, tool_hint=tool_hint, attachment_ids=attachment_ids, @@ -210,28 +208,23 @@ async def _run_agent_multi( user_id: UUID, history: list[dict], db: AsyncSession, - user_memories: list[dict] | None = None, - notebook_summary: dict | None = None, + prompt_context: PromptContextBundle | None = None, global_search: bool = False, ) -> AsyncGenerator[dict, None]: """Run the LangGraph multi-agent graph and stream SSE events.""" from app.agents.graph.multi_agent_graph import MULTI_AGENT_GRAPH - from app.agents.portrait.loader import load_latest_portrait from app.agents.graph.orchestrator import MultiAgentState - # Pre-load user portrait to inject into orchestrator context - user_portrait: dict | None = None - try: - user_portrait = await load_latest_portrait(db, user_id) - except Exception: - pass + prompt_context = prompt_context or build_prompt_context_bundle(scene="research") initial_state: MultiAgentState = { "query": query, "messages": history, - "user_memories": user_memories, - "user_portrait": user_portrait, - "notebook_summary": notebook_summary, + "user_memories": prompt_context.all_memories, + "user_portrait": prompt_context.portrait, + "notebook_summary": prompt_context.notebook_summary, + "prompt_context": prompt_context, + "active_scene": prompt_context.scene, "notebook_id": notebook_id, "user_id": str(user_id), "db": db, @@ -272,8 +265,7 @@ async def _run_agent_single( user_id: UUID, history: list[dict], db: AsyncSession, - user_memories: list[dict] | None = None, - notebook_summary: dict | None = None, + prompt_context: PromptContextBundle | None = None, global_search: bool = False, tool_hint: str | None = None, attachment_ids: list[str] | None = None, @@ -318,9 +310,7 @@ async def _run_agent_single( mcp_skill_map=mcp_skill_map, ) system_prompt = await build_system_prompt( - user_memories, - notebook_summary, - db=db, + prompt_context or build_prompt_context_bundle(scene="chat"), tool_schemas=tool_schemas, active_skills=active_skills, ) @@ -378,8 +368,10 @@ async def _run_agent_single( max_steps=MAX_ITERATIONS, query=query, global_search=global_search, - active_scene="research", - context_budget_chars=context_budget_for_scene("research"), + active_scene=(prompt_context.scene if prompt_context else "chat"), + context_budget_chars=context_budget_for_scene( + prompt_context.scene if prompt_context else "chat" + ), ) brain = AgentBrain(has_tools=bool(tool_schemas), max_steps=MAX_ITERATIONS) engine = AgentEngine( diff --git a/apps/api/app/agents/graph/orchestrator.py b/apps/api/app/agents/graph/orchestrator.py index ef8b65b..7522515 100644 --- a/apps/api/app/agents/graph/orchestrator.py +++ b/apps/api/app/agents/graph/orchestrator.py @@ -28,6 +28,7 @@ class MultiAgentState(TypedDict, total=False): user_portrait: dict | None # L4 用户画像(由 orchestrator 预加载) notebook_summary: dict | None # 笔记本摘要 active_scene: str # 当前场景标签 + prompt_context: Any # 标准化 prompt 上下文 bundle # ── 运行时传递(不可序列化,不适合持久化) ────────────────────────────── notebook_id: str @@ -198,6 +199,7 @@ async def synthesis_node(state: MultiAgentState) -> dict: 汇总节点:将深度研究专家的输出合成为流式回答。 """ from langchain_core.callbacks.manager import adispatch_custom_event + from app.agents.memory import build_prompt_context_bundle from app.agents.writing.composer import build_system_prompt, _build_context query: str = state.get("query", "") @@ -205,6 +207,7 @@ async def synthesis_node(state: MultiAgentState) -> dict: user_memories = state.get("user_memories") notebook_summary = state.get("notebook_summary") user_portrait = state.get("user_portrait") + prompt_context = state.get("prompt_context") db = state.get("db") specialist: dict = state.get("specialist_result") or {} synthesis_packet = state.get("synthesis_packet") or _build_synthesis_packet(state) @@ -217,11 +220,15 @@ async def synthesis_node(state: MultiAgentState) -> dict: # ── 构建系统 prompt(含画像)───────────────────────────────────────────── try: + if prompt_context is None: + prompt_context = build_prompt_context_bundle( + scene=state.get("active_scene", "research"), + user_memories=user_memories, + notebook_summary=notebook_summary, + portrait=user_portrait, + ) system_prompt = await build_system_prompt( - user_memories=user_memories, - notebook_summary=notebook_summary, - db=db, - user_portrait=user_portrait, + prompt_context=prompt_context, tool_schemas=[], ) except Exception: diff --git a/apps/api/app/agents/memory/__init__.py b/apps/api/app/agents/memory/__init__.py index 894b414..76a8e53 100644 --- a/apps/api/app/agents/memory/__init__.py +++ b/apps/api/app/agents/memory/__init__.py @@ -3,6 +3,7 @@ Public symbols from sub-modules: retrieval — build_memory_context, get_user_memories + prompt_context — PromptContextBundle, build_prompt_context_bundle, load_prompt_context extraction — extract_memories, _upsert_memory, reinforce_memory, mark_memory_stale, decay_stale_memories, PREFERENCE_KEYS notebook — get_notebook_summary, refresh_notebook_summary, @@ -16,6 +17,11 @@ build_memory_context, get_user_memories, ) +from app.agents.memory.prompt_context import ( # noqa: F401 + PromptContextBundle, + build_prompt_context_bundle, + load_prompt_context, +) from app.agents.memory.extraction import ( # noqa: F401 PREFERENCE_KEYS, _upsert_memory, diff --git a/apps/api/app/agents/memory/file_storage.py b/apps/api/app/agents/memory/file_storage.py index a57f26f..d7a280b 100644 --- a/apps/api/app/agents/memory/file_storage.py +++ b/apps/api/app/agents/memory/file_storage.py @@ -15,7 +15,7 @@ from __future__ import annotations -import os +import hashlib from datetime import datetime from pathlib import Path @@ -198,6 +198,12 @@ def _parse_memory_doc_sections(content: str) -> list[dict]: """ import re + def _section_key(heading: str) -> str: + normalized = re.sub(r"[^\w]+", "_", heading.strip().lower(), flags=re.UNICODE).strip("_") + if not normalized: + normalized = hashlib.sha1(heading.encode("utf-8")).hexdigest()[:12] + return "file_" + normalized[:60] + items = [] # Split on H2 headings sections = re.split(r"^## (.+)$", content, flags=re.MULTILINE) @@ -215,7 +221,7 @@ def _parse_memory_doc_sections(content: str) -> list[dict]: continue # Derive a safe key from the heading - key = "file_" + re.sub(r"[^a-z0-9_]", "_", heading.lower())[:60].strip("_") + key = _section_key(heading) items.append({ "key": key, "value": body[:500], @@ -230,24 +236,40 @@ async def sync_memory_doc_to_db(user_id, db, force: bool = False) -> int: """ Parse MEMORY.md and upsert each section as a source='file' memory record. - Normally only active when memory_mode='desktop'. Pass force=True to bypass - the mode check — used when the AI explicitly writes MEMORY.md via a skill - (update_memory_doc), so the content is always available in future conversations. + Synchronises the current MEMORY.md content into source='file' records. + Existing MEMORY.md-derived file records that no longer exist in the file + are deleted so the DB view matches the editable document. Returns the number of items synced. """ - from app.config import settings - from app.agents.memory.extraction import _upsert_memory + from sqlalchemy import select - if not force and settings.memory_mode != "desktop": - return 0 + from app.agents.memory.extraction import _upsert_memory + from app.models import UserMemory content = read_memory_doc() - if not content.strip(): - return 0 - items = _parse_memory_doc_sections(content) + keyed_items = {item["key"]: item for item in items} + + existing_records = ( + await db.execute( + select(UserMemory).where( + UserMemory.user_id == user_id, + UserMemory.source == "file", + UserMemory.evidence == "MEMORY.md", + ) + ) + ).scalars().all() + + deleted = 0 + current_keys = set(keyed_items.keys()) + for record in existing_records: + if record.key in current_keys: + continue + await db.delete(record) + deleted += 1 + count = 0 - for item in items: + for item in keyed_items.values(): try: await _upsert_memory( db, @@ -267,7 +289,7 @@ async def sync_memory_doc_to_db(user_id, db, force: bool = False) -> int: "sync_memory_doc_to_db: failed to upsert key '%s': %s", item["key"], exc ) - if count: + if count or deleted: await db.flush() return count diff --git a/apps/api/app/agents/memory/prompt_context.py b/apps/api/app/agents/memory/prompt_context.py new file mode 100644 index 0000000..ae459ba --- /dev/null +++ b/apps/api/app/agents/memory/prompt_context.py @@ -0,0 +1,170 @@ +""" +Prompt context bundle loader for chat and research scenes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models import AppConfig +from app.services.memory_service import MemoryService + +IDENTITY_MEMORY_KEYS = {"preferred_ai_name", "user_role", "communication_tone"} + + +@dataclass(frozen=True) +class PromptContextBundle: + scene: str = "chat" + ai_name: str = "AI 助手" + identity_memories: list[dict] = field(default_factory=list) + long_term_memories: list[dict] = field(default_factory=list) + conversation_summary: str | None = None + notebook_summary: dict | None = None + portrait: dict | None = None + + @property + def all_memories(self) -> list[dict]: + return [*self.identity_memories, *self.long_term_memories] + + +def _memory_priority(memory: dict[str, Any]) -> tuple[int, float]: + source = str(memory.get("source", "")).strip().lower() + if source == "manual": + return (0, -float(memory.get("confidence", 0.0) or 0.0)) + if source == "conversation": + return (1, -float(memory.get("confidence", 0.0) or 0.0)) + if source == "file": + return (2, -float(memory.get("confidence", 0.0) or 0.0)) + return (3, -float(memory.get("confidence", 0.0) or 0.0)) + + +def dedupe_runtime_memories(memories: list[dict] | None) -> list[dict]: + items = list(memories or []) + if not items: + return [] + + chosen_by_pair: dict[tuple[str, str], dict] = {} + for memory in items: + key = str(memory.get("key", "")).strip() + value = str(memory.get("value", "")).strip() + if not key or not value or key.startswith("diary_"): + continue + pair = (key, value) + existing = chosen_by_pair.get(pair) + if existing is None or _memory_priority(memory) < _memory_priority(existing): + chosen_by_pair[pair] = memory + + deduped: list[dict] = [] + seen_pairs: set[tuple[str, str]] = set() + for memory in items: + key = str(memory.get("key", "")).strip() + value = str(memory.get("value", "")).strip() + pair = (key, value) + if pair in seen_pairs: + continue + chosen = chosen_by_pair.get(pair) + if chosen is None or chosen is not memory: + continue + deduped.append(memory) + seen_pairs.add(pair) + return deduped + + +def build_prompt_context_bundle( + *, + scene: str = "chat", + ai_name: str | None = None, + user_memories: list[dict] | None = None, + conversation_summary: str | None = None, + notebook_summary: dict | None = None, + portrait: dict | None = None, +) -> PromptContextBundle: + identity_memories: list[dict] = [] + long_term_memories: list[dict] = [] + + for memory in dedupe_runtime_memories(user_memories): + key = str(memory.get("key", "")).strip() + if key in IDENTITY_MEMORY_KEYS: + identity_memories.append(memory) + else: + long_term_memories.append(memory) + + resolved_ai_name = (ai_name or "").strip() or "AI 助手" + + return PromptContextBundle( + scene=scene, + ai_name=resolved_ai_name, + identity_memories=identity_memories, + long_term_memories=long_term_memories, + conversation_summary=conversation_summary or None, + notebook_summary=notebook_summary, + portrait=portrait, + ) + + +async def load_prompt_context( + *, + user_id: UUID, + query: str, + db: AsyncSession, + scene: str = "chat", + notebook_id: UUID | None = None, + conversation_id: UUID | None = None, + include_portrait: bool = False, + top_k: int = 5, +) -> PromptContextBundle: + from app.agents.memory.notebook import get_conversation_summary, get_notebook_summary + from app.agents.memory.retrieval import build_memory_context + from app.agents.portrait.loader import load_latest_portrait + + memory_service = MemoryService(db, user_id) + await memory_service.sync_memory_doc_if_stale() + await memory_service.cleanup_runtime_memories() + + user_memories = await build_memory_context( + user_id, + query, + db, + top_k=top_k, + scene=scene, + ) + + conversation_summary = None + if conversation_id is not None: + conversation_summary = await get_conversation_summary(conversation_id, db) + + notebook_summary = None + if notebook_id is not None: + notebook_summary = await get_notebook_summary(notebook_id, db) + + portrait = None + if include_portrait: + portrait = await load_latest_portrait(db, user_id) + + ai_name = await resolve_prompt_config(db) + return build_prompt_context_bundle( + scene=scene, + ai_name=ai_name, + user_memories=user_memories, + conversation_summary=conversation_summary, + notebook_summary=notebook_summary, + portrait=portrait, + ) + + +async def resolve_prompt_config(db: AsyncSession) -> str: + ai_name = (getattr(settings, "ai_name", "") or "").strip() + if not ai_name: + result = await db.execute( + select(AppConfig).where(AppConfig.key == "ai_name") + ) + row = result.scalar_one_or_none() + ai_name = (row.value if row else "") or "" + + return ai_name.strip() or "AI 助手" diff --git a/apps/api/app/agents/portrait/synthesizer.py b/apps/api/app/agents/portrait/synthesizer.py index 36ea9c8..93df8f0 100644 --- a/apps/api/app/agents/portrait/synthesizer.py +++ b/apps/api/app/agents/portrait/synthesizer.py @@ -95,6 +95,9 @@ async def synthesize_portrait( from app.models import AgentReflection, UserMemory, UserPortrait from app.providers.llm import chat from app.providers.llm import get_utility_model + from app.services.memory_service import MemoryService + + await MemoryService(db, user_id).cleanup_runtime_memories() # ── 1. 加载记忆碎片 ────────────────────────────────────────────────────── mem_rows = ( diff --git a/apps/api/app/agents/rag/ingestion.py b/apps/api/app/agents/rag/ingestion.py index a3e2f0b..dd40cde 100644 --- a/apps/api/app/agents/rag/ingestion.py +++ b/apps/api/app/agents/rag/ingestion.py @@ -13,12 +13,16 @@ import logging import os +import zipfile +from io import BytesIO +from xml.etree import ElementTree as ET from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models import Chunk, Source +from app.services.monitoring_service import traced_span logger = logging.getLogger(__name__) @@ -34,7 +38,7 @@ async def ingest( db: AsyncSession, chunk_size: int = DEFAULT_CHUNK_SIZE, chunk_overlap: int = DEFAULT_CHUNK_OVERLAP, - splitter_type: str = "auto", + splitter_type: str = "recursive", separators: list[str] | None = None, min_chunk_size: int = 50, ) -> None: @@ -47,7 +51,14 @@ async def ingest( await db.flush() try: - text, chunk_metadata = await _extract_text_with_metadata(source) + async with traced_span( + db, + "source_ingest.parse", + component="ingest", + span_kind="phase", + metadata={"source_type": source.type, "storage_backend": source.storage_backend}, + ): + text, chunk_metadata = await _extract_text_with_metadata(source) if not text or len(text.strip()) < 50: source.status = "failed" @@ -59,14 +70,26 @@ async def ingest( source.raw_text = text - chunks_with_meta = _chunk_text_with_metadata( - text, chunk_metadata, - chunk_size=chunk_size, - chunk_overlap=chunk_overlap, - splitter_type=splitter_type, - separators=separators, - min_chunk_size=min_chunk_size, - ) + async with traced_span( + db, + "source_ingest.chunk", + component="ingest", + span_kind="phase", + metadata={ + "chunk_size": chunk_size, + "chunk_overlap": chunk_overlap, + "splitter_type": splitter_type, + "min_chunk_size": min_chunk_size, + }, + ): + chunks_with_meta = _chunk_text_with_metadata( + text, chunk_metadata, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + splitter_type=splitter_type, + separators=separators, + min_chunk_size=min_chunk_size, + ) chunk_texts = [c["text"] for c in chunks_with_meta] chunk_metas = [c["metadata"] for c in chunks_with_meta] @@ -74,63 +97,46 @@ async def ingest( batch_size = 100 all_embeddings: list[list[float]] = [] - for i in range(0, len(chunk_texts), batch_size): - batch = chunk_texts[i : i + batch_size] - vecs = await embed_texts(batch) - all_embeddings.extend(vecs) - - # Delete existing chunks before re-indexing - existing = await db.execute(select(Chunk).where(Chunk.source_id == source.id)) - for c in existing.scalars().all(): - await db.delete(c) - - for idx, (text_chunk, vec, meta) in enumerate( - zip(chunk_texts, all_embeddings, chunk_metas) + async with traced_span( + db, + "source_ingest.embed", + component="ingest", + span_kind="phase", + metadata={"chunk_count": len(chunk_texts), "batch_size": batch_size}, ): - chunk = Chunk( - source_id=source.id, - notebook_id=source.notebook_id, - content=text_chunk, - chunk_index=idx, - embedding=vec, - token_count=len(text_chunk.split()), - metadata_=meta if meta else None, - ) - db.add(chunk) - - source.summary = await _generate_summary(text[:3000]) - source.status = "indexed" - await db.flush() - - from app.agents.memory import refresh_notebook_summary - try: - await refresh_notebook_summary(source.notebook_id, db) - except Exception as mem_exc: - logger.warning("Notebook summary refresh failed: %s", mem_exc) - - try: - from app.workers.tasks import extract_knowledge_graph - extract_knowledge_graph.delay(str(source.id)) - except Exception: - pass - - try: - from app.models import ProactiveInsight, Notebook as NbModel - nb_result = await db.execute( - select(NbModel.user_id).where(NbModel.id == source.notebook_id) - ) - user_id = nb_result.scalar_one_or_none() - if user_id: - insight = ProactiveInsight( - user_id=user_id, + for i in range(0, len(chunk_texts), batch_size): + batch = chunk_texts[i : i + batch_size] + vecs = await embed_texts(batch) + all_embeddings.extend(vecs) + + async with traced_span( + db, + "source_ingest.index", + component="ingest", + span_kind="phase", + metadata={"chunk_count": len(chunk_texts)}, + ): + existing = await db.execute(select(Chunk).where(Chunk.source_id == source.id)) + for c in existing.scalars().all(): + await db.delete(c) + + for idx, (text_chunk, vec, meta) in enumerate( + zip(chunk_texts, all_embeddings, chunk_metas) + ): + chunk = Chunk( + source_id=source.id, notebook_id=source.notebook_id, - insight_type="source_indexed", - title=f"「{source.title or '新资料'}」已完成索引", - content=source.summary[:200] if source.summary else None, + content=text_chunk, + chunk_index=idx, + embedding=vec, + token_count=len(text_chunk.split()), + metadata_=meta if meta else None, ) - db.add(insight) - except Exception: - pass + db.add(chunk) + + source.summary = _build_fallback_summary(text) + source.status = "indexed" + await db.flush() except Exception as exc: source.status = "failed" @@ -144,6 +150,21 @@ def _sanitize(text: str) -> str: return text.replace("\x00", "") +def _source_extension(source: Source) -> str: + candidate = source.storage_key or source.file_path or source.title or "" + return os.path.splitext(candidate)[1].lower() + + +def _build_fallback_summary(text: str, limit: int = 200) -> str: + """Create a lightweight preview summary without an extra LLM roundtrip.""" + condensed = " ".join(text.split()) + if not condensed: + return "" + if len(condensed) <= limit: + return condensed + return condensed[: limit - 3].rstrip() + "..." + + # --------------------------------------------------------------------------- # Text extraction with metadata # --------------------------------------------------------------------------- @@ -156,8 +177,9 @@ async def _extract_text_with_metadata(source: Source) -> tuple[str, list[dict]]: that is later used when chunking to attach context to each chunk. """ from app.providers.storage import storage as get_storage + ext = _source_extension(source) - if source.type == "pdf": + if source.type == "pdf" or ext == ".pdf": if source.storage_key: content = await get_storage().download(source.storage_key) raw, meta = _parse_pdf_bytes(content) @@ -165,7 +187,16 @@ async def _extract_text_with_metadata(source: Source) -> tuple[str, list[dict]]: raw, meta = _parse_pdf_file(source.file_path) else: raw, meta = "", [] - elif source.type in ("md", "txt"): + elif ext == ".docx": + if source.storage_key: + content = await get_storage().download(source.storage_key) + raw, meta = _parse_docx_bytes(content) + elif source.file_path: + with open(source.file_path, "rb") as f: + raw, meta = _parse_docx_bytes(f.read()) + else: + raw, meta = "", [] + elif source.type in ("md", "txt", "doc"): if source.storage_key: content = await get_storage().download(source.storage_key) raw = content.decode("utf-8", errors="ignore") @@ -222,6 +253,28 @@ def _parse_pdf_file(file_path: str) -> tuple[str, list[dict]]: return _parse_pdf_bytes(f.read()) +def _parse_docx_bytes(content: bytes) -> tuple[str, list[dict]]: + """ + Parse DOCX bytes by reading the underlying WordprocessingML document. + This avoids treating the ZIP payload as plain text and extracting binary garbage. + """ + namespace = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} + + with zipfile.ZipFile(BytesIO(content)) as docx_zip: + xml = docx_zip.read("word/document.xml") + + root = ET.fromstring(xml) + paragraphs: list[str] = [] + + for paragraph in root.findall(".//w:p", namespace): + texts = [node.text or "" for node in paragraph.findall(".//w:t", namespace)] + line = "".join(texts).strip() + if line: + paragraphs.append(line) + + return "\n\n".join(paragraphs), [] + + def _parse_pdf_bytes_pypdf(content: bytes) -> str: """Legacy pypdf fallback (no OCR, no metadata).""" import io @@ -321,7 +374,7 @@ def _chunk_text_with_metadata( Split text into chunks and attach per-chunk metadata. splitter_type controls which splitter is used: - auto – try SemanticChunker first, fall back to recursive + auto – 默认使用递归切分,保证导入链路稳定 semantic – force SemanticChunker recursive – force RecursiveCharacterTextSplitter @@ -355,8 +408,8 @@ def _split_text( return _semantic_split(text, chunk_size, chunk_overlap) if splitter_type == "recursive": return _recursive_split(text, chunk_size, chunk_overlap, separators=separators) - # auto: try semantic, fall back to recursive - return _semantic_split_with_fallback(text, chunk_size, chunk_overlap, separators=separators) + # auto: prefer the stable recursive splitter for background ingestion. + return _recursive_split(text, chunk_size, chunk_overlap, separators=separators) def _semantic_split(text: str, chunk_size: int, chunk_overlap: int) -> list[str]: diff --git a/apps/api/app/agents/research/deep_research.py b/apps/api/app/agents/research/deep_research.py index 04a0930..605f8ba 100644 --- a/apps/api/app/agents/research/deep_research.py +++ b/apps/api/app/agents/research/deep_research.py @@ -26,7 +26,7 @@ from langgraph.graph import END, START, StateGraph from langgraph.types import Send from openai import AsyncOpenAI -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.agents.core.genui_protocol import GENUI_PROTOCOL_REPORT as _GENUI_PROTOCOL_REPORT from app.services.monitoring_service import ( @@ -87,6 +87,7 @@ class ResearchState(TypedDict): user_memories: list[dict] mode: str # "quick" | "deep" clarification_context: list[dict] | None # [{question, answer}, ...] + plan_override: dict | None # pre-generated plan from /plan endpoint # ── plan_node output ─────────────────────────────────────────────────────── report_title: str @@ -281,10 +282,17 @@ async def web_search_sync(query: str, tavily_api_key: str, max_results: int = 4) # ── Research primitives ─────────────────────────────────────────────────────── async def generate_clarifying_questions(query: str, client: AsyncOpenAI, model: str) -> list[dict]: - """Generate 4 query-specific clarifying questions with 3 options each.""" + """Generate clarifying questions only when the query is genuinely ambiguous. + + Returns an empty list if the query is already specific enough so the frontend + can skip the questionnaire and proceed directly to plan generation. + """ system_prompt = ( - "你是一位研究助手,需要通过几个简短选择题了解用户的研究偏好,以便生成更精准的深度研究报告。\n\n" - "根据用户的研究主题,生成4个选择题,每题3个选项。问题须涵盖:\n" + "你是一位研究助手,负责判断用户的研究课题是否需要进一步明确研究偏好。\n\n" + "判断标准:\n" + "- 如果课题已经足够具体(明确了研究方向、范围、目标等),则不需要提问,直接返回空列表。\n" + "- 如果课题较宽泛或存在多种合理解读方向,则生成2-4个选择题帮助明确偏好。\n\n" + "当需要提问时,问题须按需从以下维度选取(不必全选):\n" "1. 研究侧重(选项必须与该主题强相关,不可用通用词汇)\n" "2. 目标读者(专业研究者 / 行业从业者 / 普通读者)\n" "3. 时间维度(最新进展为主 / 历史演进 / 不限)\n" @@ -293,7 +301,8 @@ async def generate_clarifying_questions(query: str, client: AsyncOpenAI, model: "- 第1题的选项必须高度贴合研究主题,体现该领域的核心分支或方向\n" "- 选项 value 使用简洁英文关键词,label 使用中文\n" "- 只返回JSON,不含其他文字\n\n" - '输出格式:{"questions": [{"question": "...", "options": [{"label": "...", "value": "..."}, ...]}, ...]}' + '输出格式:{"questions": [{"question": "...", "options": [{"label": "...", "value": "..."}, ...]}, ...]}\n' + '课题清晰无需提问时返回:{"questions": []}' ) try: resp = await client.chat.completions.create( @@ -309,45 +318,13 @@ async def generate_clarifying_questions(query: str, client: AsyncOpenAI, model: raw = _strip_fences((resp.choices[0].message.content or "").strip()) result = json.loads(raw) questions = result.get("questions", []) - if questions and len(questions) >= 2: + if isinstance(questions, list): return questions except Exception: pass - return [ - { - "question": "研究侧重是什么?", - "options": [ - {"label": "理论原理", "value": "theory"}, - {"label": "实践应用", "value": "practice"}, - {"label": "两者兼顾", "value": "both"}, - ], - }, - { - "question": "您的目标读者是?", - "options": [ - {"label": "专业研究者", "value": "researcher"}, - {"label": "行业从业者", "value": "practitioner"}, - {"label": "普通读者", "value": "general"}, - ], - }, - { - "question": "时间维度侧重?", - "options": [ - {"label": "最新进展(近1年)", "value": "recent"}, - {"label": "历史演进与现状", "value": "history"}, - {"label": "不限时间", "value": "all"}, - ], - }, - { - "question": "报告输出风格?", - "options": [ - {"label": "综合综述", "value": "overview"}, - {"label": "技术深度分析", "value": "technical"}, - {"label": "案例驱动", "value": "case_study"}, - ], - }, - ] + # LLM call failed — skip clarification so the user isn't blocked + return [] async def _plan( @@ -384,17 +361,19 @@ async def _plan( { "role": "system", "content": ( - "你是一位资深研究规划师,擅长将模糊问题拆解为精准、深度的研究子问题。\n\n" + "你是一位资深研究规划师,负责为深度研究任务制定具体的研究行动计划。\n\n" "## 核心要求\n" - "1. **深度拆解**:不要简单重复用户的问题,要从不同角度深入挖掘\n" - "2. **具体化**:每个查询必须是精确的、可搜索的、包含明确方向的问题\n" - "3. **多层次**:涵盖基础原理→应用实践→前沿进展→对比评价等不同深度\n\n" - "## 每个维度的查询要求\n" + "1. **任务描述风格**:每条研究任务必须以动词开头(梳理/检索/调查/分析/对比/汇总等)," + "描述'要做什么、覆盖什么范围、关注什么重点',而非写成疑问句\n" + "2. **具体且可执行**:每条任务需包含明确的时间范围、具体对象或技术点," + "让搜索引擎能精确检索,例如:'梳理2022-2023年ReAct、Reflexion等推理框架的提出背景与核心机制'\n" + "3. **叙述完整**:每条任务应是一个完整的研究动作描述,不要只写关键词\n\n" + "## 每个维度的任务要求\n" + dim_desc + "## 输出格式\n" "只返回一个JSON对象,不含任何其他文字:\n" - '{"title": "研究报告标题(学术风格,10-25字,不要直接复述用户问题,而是提炼出研究主题的本质,例如用户问\'什么是ReAct\'应生成\'ReAct框架:推理与行动的协同机制\')",' - '"research_goal": "一句话描述研究目标(需包含具体研究范围和预期产出)",' + '{"title": "研究报告标题(学术风格,10-25字,提炼研究主题本质,如\'ReAct框架:推理与行动的协同机制\')",' + '"research_goal": "一句话描述研究目标(含具体研究范围和预期产出)",' '"evaluation_criteria": ["标准1", "标准2", "标准3"],' '"search_matrix": {' f'"concept": {example_arr},' @@ -846,6 +825,7 @@ def create_research_graph( db: AsyncSession, client: AsyncOpenAI, tavily_api_key: str | None, + db_session_factory: async_sessionmaker[AsyncSession] | None = None, ): """ Build and compile the research StateGraph with injected dependencies. @@ -857,31 +837,59 @@ def create_research_graph( async def plan_node(state: ResearchState) -> dict: cfg = MODES.get(state.get("mode", "quick"), MODES["quick"]) await adispatch_custom_event("plan", {"status": "planning"}) - async with traced_span( - db, - "research.plan", - metadata={"query_snapshot": build_text_snapshot(state["query"])}, - ): - plan_result = await _plan( - state["query"], client, state["model"], - queries_per_dim=cfg.queries_per_dim, - clarification_context=state.get("clarification_context"), - db=db, - ) - all_queries = [q for qs in plan_result["search_matrix"].values() for q in qs] - report_title = plan_result.get("title") or state["query"][:25] + + override = state.get("plan_override") + if override: + # Use pre-generated plan from /ai/deep-research/plan endpoint. + # Prefer the structured search_matrix when present (preserves dimension + # grouping); fall back to rechunking the flat sub_questions list. + if override.get("search_matrix"): + search_matrix = { + dim: queries or [state["query"]] + for dim, queries in override["search_matrix"].items() + } + sub_questions = [q for qs in search_matrix.values() for q in qs] + else: + sub_questions = override.get("sub_questions", []) + dims = ("concept", "latest", "evidence", "controversy") + chunk = max(1, len(sub_questions) // len(dims)) + search_matrix = { + dim: sub_questions[i * chunk: (i + 1) * chunk] or [state["query"]] + for i, dim in enumerate(dims) + } + report_title = override.get("report_title") or state["query"][:25] + research_goal = override.get("research_goal", "") + evaluation_criteria = override.get("evaluation_criteria", []) + else: + async with traced_span( + db, + "research.plan", + metadata={"query_snapshot": build_text_snapshot(state["query"])}, + ): + plan_result = await _plan( + state["query"], client, state["model"], + queries_per_dim=cfg.queries_per_dim, + clarification_context=state.get("clarification_context"), + db=db, + ) + search_matrix = plan_result["search_matrix"] + report_title = plan_result.get("title") or state["query"][:25] + research_goal = plan_result["research_goal"] + evaluation_criteria = plan_result["evaluation_criteria"] + sub_questions = [q for qs in search_matrix.values() for q in qs] + await adispatch_custom_event("plan", { - "research_goal": plan_result["research_goal"], - "sub_questions": all_queries, - "search_matrix": plan_result["search_matrix"], - "evaluation_criteria": plan_result["evaluation_criteria"], + "research_goal": research_goal, + "sub_questions": sub_questions, + "search_matrix": search_matrix, + "evaluation_criteria": evaluation_criteria, "report_title": report_title, }) return { "report_title": report_title, - "research_goal": plan_result["research_goal"], - "evaluation_criteria": plan_result["evaluation_criteria"], - "search_matrix": plan_result["search_matrix"], + "research_goal": research_goal, + "evaluation_criteria": evaluation_criteria, + "search_matrix": search_matrix, } async def search_node(state: dict) -> dict: @@ -890,23 +898,36 @@ async def search_node(state: dict) -> dict: query: str = state["query"] dimension: str = state["dimension"] await adispatch_custom_event("searching", {"query": query, "dimension": dimension}) - async with traced_span( - db, - "research.search", - metadata={"query_snapshot": build_text_snapshot(query), "dimension": dimension}, - ): - learning = await _research_one( - query=query, - dimension=dimension, - notebook_id=state["notebook_id"], - user_id=state["user_id"], - db=db, - client=client, - tavily_api_key=tavily_api_key, - model=state["model"], - max_web_results=cfg.web_results, - learning_max_chars=cfg.learning_max_chars, - ) + if db_session_factory is None: + from app.database import AsyncSessionLocal + + node_session_factory = AsyncSessionLocal + else: + node_session_factory = db_session_factory + + async with node_session_factory() as node_db: + try: + async with traced_span( + node_db, + "research.search", + metadata={"query_snapshot": build_text_snapshot(query), "dimension": dimension}, + ): + learning = await _research_one( + query=query, + dimension=dimension, + notebook_id=state["notebook_id"], + user_id=state["user_id"], + db=node_db, + client=client, + tavily_api_key=tavily_api_key, + model=state["model"], + max_web_results=cfg.web_results, + learning_max_chars=cfg.learning_max_chars, + ) + await node_db.commit() + except Exception: + await node_db.rollback() + raise await adispatch_custom_event("learning", { "question": query, "content": learning.content, diff --git a/apps/api/app/agents/research/task_manager.py b/apps/api/app/agents/research/task_manager.py index 2c1ed52..abb8a15 100644 --- a/apps/api/app/agents/research/task_manager.py +++ b/apps/api/app/agents/research/task_manager.py @@ -16,7 +16,7 @@ from collections.abc import AsyncGenerator from datetime import datetime, timezone -from sqlalchemy import update +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database import AsyncSessionLocal @@ -116,6 +116,7 @@ async def run_research_task( tavily_api_key: str | None, user_memories: list[dict], clarification_context: list[dict] | None = None, + plan_override: dict | None = None, trace_id: str | None = None, ) -> None: """Background coroutine: run LangGraph, push events to buffer, save to DB.""" @@ -149,11 +150,13 @@ async def run_research_task( "query_snapshot": build_text_snapshot(query), }, ) + await db.commit() trace_token, run_token = bind_trace_and_run(trace_id or task_id, run.id) graph = create_research_graph( db=db, client=client, tavily_api_key=tavily_api_key, + db_session_factory=AsyncSessionLocal, ) input_state = { @@ -165,6 +168,7 @@ async def run_research_task( "user_memories": user_memories, "mode": mode, "clarification_context": clarification_context, + "plan_override": plan_override, "research_goal": "", "evaluation_criteria": [], "search_matrix": {}, @@ -192,6 +196,7 @@ async def run_research_task( "query_snapshot": build_text_snapshot(query), }, ) + await db.commit() async for event in graph.astream_events(input_state, version="v2"): if event["event"] != "on_custom_event": diff --git a/apps/api/app/agents/soul/soul.py b/apps/api/app/agents/soul/soul.py index c87a9f6..2deba3a 100644 --- a/apps/api/app/agents/soul/soul.py +++ b/apps/api/app/agents/soul/soul.py @@ -34,7 +34,7 @@ _THINK_LOOP_INTERVAL = 120 # 2 分钟扫描一次活跃用户 # 同一用户两次「浮现」推送之间的最小间隔(秒) -_SURFACE_COOLDOWN = 480 # 8 分钟 +_SURFACE_COOLDOWN = 1_800 # 30 分钟 # LLM 思考 prompt _SOUL_MONOLOGUE_PROMPT = """你是 Lyra,一个内嵌在个人知识管理应用中的 AI 助手。 @@ -159,6 +159,8 @@ async def _think( content = result.get("content", "") should_surface = result.get("should_surface", False) + if _should_force_silence(activity): + should_surface = False # 持久化到数据库 notebook_id = activity.get("notebook_id") @@ -249,3 +251,12 @@ async def _store_thought( await db.commit() except Exception: logger.warning("Failed to store agent thought to DB", exc_info=True) + + +def _should_force_silence(activity: dict) -> bool: + """前端明确提示为高打扰场景时,强制仅存内部思考。""" + return bool( + activity.get("typing_recently") + or activity.get("copilot_open") + or activity.get("is_mobile") + ) diff --git a/apps/api/app/agents/writing/composer.py b/apps/api/app/agents/writing/composer.py index ddc5cf5..8df3ced 100644 --- a/apps/api/app/agents/writing/composer.py +++ b/apps/api/app/agents/writing/composer.py @@ -9,14 +9,17 @@ from collections.abc import AsyncGenerator from typing import TYPE_CHECKING, Literal +from app.agents.memory.prompt_context import ( + IDENTITY_MEMORY_KEYS, + PromptContextBundle, + build_prompt_context_bundle, +) + if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession from app.skills.base import SkillBase ArtifactType = Literal["summary", "faq", "study_guide", "briefing", "outline"] -_IDENTITY_MEMORY_KEYS = {"preferred_ai_name", "user_role", "communication_tone"} - _CLAUDE_CODE_INSPIRED_GUIDANCE = """## LyraNote 风格执行纪律 ### 用户可见文本 - 用户只能直接看到你输出的文字,通常看不到你的工具调用与内部推理;因此你的文字必须足够自解释 @@ -40,14 +43,14 @@ - 已经确认成功的步骤就直接说明,不要过度防御性地弱化结果 """ -_BASE_SYSTEM_PROMPT_TEMPLATE = """你是 {ai_name},一位专属 AI 研究助手,帮助用户深入理解和研究笔记本中的资料。 +_BASE_SYSTEM_PROMPT_TEMPLATE = """{role_intro} ## 工具使用规则 你拥有一系列真实工具,调用后会直接对用户产生效果。 工具产生的可视化输出(思维导图、架构图等)已直接展示给用户,你只需简短确认,不要用文字重复工具已完成的输出。 ## 推理规则 -当你决定调用工具时,先用一句话简要说明意图(如"我需要检索知识库确认已有研究"),再执行调用。 +当你决定调用工具时,先用一句话简要说明意图(如"我先查看相关资料"),再执行调用。 当你决定不调用工具时,直接自然回答即可,无需说明理由。 多步骤任务时,每一步简要说明当前进展和下一步计划。 @@ -91,8 +94,12 @@ - 选择卡片后面不要再写其他内容 不需要检索时请直接、自然地回答,无需引用。 +""" -{custom_addon}""" +_SCENE_ROLE_INTROS = { + "chat": "你是 {ai_name},一位 AI 笔记助手,帮助用户处理日常问答、知识整理与写作思考。", + "research": "你是 {ai_name},一位专属 AI 研究助手,帮助用户深入理解和研究笔记本中的资料。", +} ARTIFACT_PROMPTS: dict[str, str] = { "summary": ( @@ -147,7 +154,7 @@ def _format_user_memory_sections(user_memories: list[dict]) -> list[str]: continue key = str(memory.get("key", "")).strip() value = str(memory.get("value", "")).strip() - if not key or not value or key in _IDENTITY_MEMORY_KEYS: + if not key or not value or key in IDENTITY_MEMORY_KEYS: continue grouped.setdefault(_resolve_memory_kind(memory), []).append(memory) @@ -186,7 +193,7 @@ def _format_user_memory_sections(user_memories: list[dict]) -> list[str]: ) -def _build_static_section(ai_name: str, custom_addon: str) -> str: +def _build_static_section(ai_name: str, scene: str) -> str: """Cacheable behavioral rules — identical across all sessions for a given config. Contains: identity intro, tool/reasoning/MCP rules, execution discipline. @@ -194,8 +201,9 @@ def _build_static_section(ai_name: str, custom_addon: str) -> str: """ from app.agents.core.genui_protocol import GENUI_PROTOCOL + role_intro = _SCENE_ROLE_INTROS.get(scene, _SCENE_ROLE_INTROS["chat"]).format(ai_name=ai_name) return "\n".join([ - _BASE_SYSTEM_PROMPT_TEMPLATE.format(ai_name=ai_name, custom_addon=custom_addon), + _BASE_SYSTEM_PROMPT_TEMPLATE.format(role_intro=role_intro), _CLAUDE_CODE_INSPIRED_GUIDANCE, GENUI_PROTOCOL, ]) @@ -203,19 +211,15 @@ def _build_static_section(ai_name: str, custom_addon: str) -> str: async def _build_dynamic_section( *, + prompt_context: PromptContextBundle, identity_lines: list[str], active_skills: "list[SkillBase] | None", - user_memories: list[dict] | None, - user_portrait: dict | None, - notebook_summary: dict | None, ) -> str: """Per-session context that changes across requests. Contains: identity overrides, skills, user memory, portrait, notebook context, scene instruction. Appended after the static boundary. """ - from app.config import settings - parts: list[str] = [] # Identity overrides from memory (e.g. preferred name, user role) @@ -243,56 +247,36 @@ async def _build_dynamic_section( except Exception: pass - # Long-term memory (file-based) - try: - from app.agents.memory import get_memory_doc_content, get_recent_diary_notes - memory_content = get_memory_doc_content() - if memory_content.strip(): - parts.append(f"## 关于用户的长期记忆\n{memory_content.strip()}") - diary_notes = await get_recent_diary_notes(limit=3) - if diary_notes: - parts.append(f"## 近期对话摘要\n{diary_notes}") - except Exception: - pass + if prompt_context.conversation_summary: + parts.append(f"## 当前会话较早期摘要\n{prompt_context.conversation_summary.strip()}") # User portrait (pre-loaded by orchestrator) - if user_portrait: + if prompt_context.portrait: try: portrait_lines: list[str] = [] - if identity_summary := user_portrait.get("identity_summary", ""): + if identity_summary := prompt_context.portrait.get("identity_summary", ""): portrait_lines.append(identity_summary) - if current_focus := user_portrait.get("research_trajectory", {}).get("current_focus", ""): + if current_focus := prompt_context.portrait.get("research_trajectory", {}).get("current_focus", ""): portrait_lines.append(f"当前研究重心:{current_focus}") - if expertise := user_portrait.get("identity", {}).get("expertise_level", ""): + if expertise := prompt_context.portrait.get("identity", {}).get("expertise_level", ""): portrait_lines.append(f"知识水平:{expertise}") - if answer_fmt := user_portrait.get("interaction_style", {}).get("answer_format", ""): + if answer_fmt := prompt_context.portrait.get("interaction_style", {}).get("answer_format", ""): portrait_lines.append(f"偏好回答格式:{answer_fmt}") - if lyra_notes := user_portrait.get("lyra_service_notes", ""): + if lyra_notes := prompt_context.portrait.get("lyra_service_notes", ""): portrait_lines.append(f"Lyra 注意:{lyra_notes}") if portrait_lines: parts.append("## Lyra 对你的长期认知(用户画像)\n" + "\n".join(portrait_lines)) except Exception: pass - # Basic user profile from settings - occupation = getattr(settings, "user_occupation", "") or "" - preferences = getattr(settings, "user_preferences", "") or "" - if occupation or preferences: - profile_lines = [] - if occupation: - profile_lines.append(f" - 职业:{occupation}") - if preferences: - profile_lines.append(f" - 偏好/兴趣:{preferences}") - parts.append("关于用户的基本信息(来自初始化配置):\n" + "\n".join(profile_lines)) - # Structured memory sections from conversation history - if user_memories: - parts.extend(_format_user_memory_sections(user_memories)) + if prompt_context.long_term_memories: + parts.extend(_format_user_memory_sections(prompt_context.long_term_memories)) # Notebook context - if notebook_summary and notebook_summary.get("summary_md"): - themes = "、".join(notebook_summary.get("key_themes") or []) - nb_ctx = f"当前笔记本研究背景:{notebook_summary['summary_md']}" + if prompt_context.notebook_summary and prompt_context.notebook_summary.get("summary_md"): + themes = "、".join(prompt_context.notebook_summary.get("key_themes") or []) + nb_ctx = f"当前笔记本研究背景:{prompt_context.notebook_summary['summary_md']}" if themes: nb_ctx += f"\n核心主题:{themes}" parts.append(nb_ctx) @@ -304,13 +288,9 @@ async def _build_dynamic_section( async def build_system_prompt( - user_memories: list[dict] | None = None, - notebook_summary: dict | None = None, - scene_instruction: str | None = None, # kept for backward compat, unused - db: "AsyncSession | None" = None, + prompt_context: PromptContextBundle, tool_schemas: list[dict] | None = None, active_skills: list["SkillBase"] | None = None, - user_portrait: dict | None = None, ) -> str: """Compose a personalised system prompt. @@ -319,48 +299,24 @@ async def build_system_prompt( that support prompt caching (Anthropic) split on this marker and apply ``cache_control`` to the static block. """ - from app.config import settings - - # Resolve ai_name - ai_name = (getattr(settings, "ai_name", "") or "").strip() - if not ai_name and db is not None: - try: - from app.models import AppConfig - from sqlalchemy import select as _select - _row = (await db.execute( - _select(AppConfig).where(AppConfig.key == "ai_name") - )).scalar_one_or_none() - if _row and _row.value: - ai_name = _row.value.strip() - try: - settings.ai_name = ai_name - except Exception: - pass - except Exception: - pass - if not ai_name: - ai_name = "AI 助手" - - custom_system_prompt = getattr(settings, "custom_system_prompt", "") or "" - custom_addon = f"\n\n## 额外指导\n{custom_system_prompt}" if custom_system_prompt.strip() else "" + ai_name = prompt_context.ai_name or "AI 助手" # Extract identity overrides from memories (needed by both sections) identity_lines: list[str] = [] preferred_ai_name: str | None = None user_role: str | None = None communication_tone: str | None = None - if user_memories: - for mem in user_memories: - key = str(mem.get("key", "")).strip() - value = str(mem.get("value", "")).strip() - if not key or not value: - continue - if key == "preferred_ai_name": - preferred_ai_name = value - elif key == "user_role": - user_role = value - elif key == "communication_tone": - communication_tone = value + for mem in prompt_context.identity_memories: + key = str(mem.get("key", "")).strip() + value = str(mem.get("value", "")).strip() + if not key or not value: + continue + if key == "preferred_ai_name": + preferred_ai_name = value + elif key == "user_role": + user_role = value + elif key == "communication_tone": + communication_tone = value if preferred_ai_name: ai_name = preferred_ai_name identity_lines.append(f" - 你的名字/称呼:{preferred_ai_name}(用户明确指定,必须用此名称自我介绍)") @@ -369,13 +325,11 @@ async def build_system_prompt( if communication_tone: identity_lines.append(f" - 语气风格:{communication_tone}(所有回复必须体现此语气)") - static = _build_static_section(ai_name, custom_addon) + static = _build_static_section(ai_name, prompt_context.scene) dynamic = await _build_dynamic_section( + prompt_context=prompt_context, identity_lines=identity_lines, active_skills=active_skills, - user_memories=user_memories, - user_portrait=user_portrait, - notebook_summary=notebook_summary, ) return static + _STATIC_DYNAMIC_BOUNDARY + dynamic @@ -405,9 +359,7 @@ async def compose_answer( query: str, chunks: list[dict], history: list[dict], - user_memories: list[dict] | None = None, - notebook_summary: dict | None = None, - db: "AsyncSession | None" = None, + prompt_context: PromptContextBundle | None = None, *, extra_graph_context: str | None = None, ) -> tuple[str, list[dict]]: @@ -422,7 +374,7 @@ async def compose_answer( if context else f"## 结构化知识关联(图谱)\n{eg}" ) - messages = await _build_messages(query, context, history, user_memories, notebook_summary, db) + messages = await _build_messages(query, context, history, prompt_context) answer = await chat(messages) return answer, citations @@ -431,9 +383,7 @@ async def stream_answer( query: str, chunks: list[dict], history: list[dict], - user_memories: list[dict] | None = None, - notebook_summary: dict | None = None, - db: "AsyncSession | None" = None, + prompt_context: PromptContextBundle | None = None, ) -> AsyncGenerator[dict, None]: """ Streaming: yield dicts of shape: @@ -444,7 +394,7 @@ async def stream_answer( from app.providers.llm import chat_stream context, citations = _build_context(chunks) - messages = await _build_messages(query, context, history, user_memories, notebook_summary, db) + messages = await _build_messages(query, context, history, prompt_context) async for chunk in chat_stream(messages): yield chunk @@ -477,11 +427,10 @@ async def _build_messages( query: str, context: str, history: list[dict], - user_memories: list[dict] | None = None, - notebook_summary: dict | None = None, - db: "AsyncSession | None" = None, + prompt_context: PromptContextBundle | None = None, ) -> list[dict]: - system = await build_system_prompt(user_memories, notebook_summary, db=db) + prompt_context = prompt_context or build_prompt_context_bundle(scene="chat") + system = await build_system_prompt(prompt_context) messages: list[dict] = [{"role": "system", "content": system}] if context: diff --git a/apps/api/app/agents/writing/ghost_text.py b/apps/api/app/agents/writing/ghost_text.py index 62674d5..2d7809c 100644 --- a/apps/api/app/agents/writing/ghost_text.py +++ b/apps/api/app/agents/writing/ghost_text.py @@ -1,18 +1,27 @@ """ Writing Agent: inline AI actions for the Tiptap editor. - Ghost Text suggestion (autocomplete) -- Selection rewrite (polish / shorten / expand) +- Selection rewrite (polish / proofread / reformat / shorten / expand) """ from typing import Literal -RewriteAction = Literal["polish", "shorten", "expand"] +RewriteAction = Literal["polish", "proofread", "reformat", "shorten", "expand"] REWRITE_PROMPTS: dict[str, str] = { "polish": ( "请对以下文字进行润色,使其表达更流畅、专业。保持原意,不要大幅改写。" "直接输出改写后的文字,不加任何解释。" ), + "proofread": ( + "请校对以下文字,修正错别字、语病、标点和不自然表达。" + "保持原意与语气,直接输出校对后的文字,不加任何解释。" + ), + "reformat": ( + "请重新整理以下文字的格式,使结构更清晰、阅读更顺畅。" + "可以调整断句、换行和列表表达,但不要添加无关内容。" + "直接输出整理后的文字,不加任何解释。" + ), "shorten": ( "请将以下文字进行精简,在保留核心信息的前提下尽量缩短。" "直接输出精简后的文字,不加任何解释。" diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 8dd4846..153246f 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -1,3 +1,5 @@ +from pathlib import Path + from pydantic_settings import BaseSettings, SettingsConfigDict from typing import Literal @@ -89,7 +91,6 @@ class Settings(BaseSettings): ai_name: str = "" user_occupation: str = "" user_preferences: str = "" - custom_system_prompt: str = "" # Memory file storage (file-based, desktop-app friendly) # Default: ~/.lyranote/memory/ — override via MEMORY_DIR env var @@ -113,6 +114,10 @@ class Settings(BaseSettings): app_base_url: str = "http://localhost:8000" api_prefix: str = "/api/v1" # used for OAuth redirect_uri (routes are mounted under this) frontend_url: str = "http://localhost:3000" + runtime_profile: Literal["server", "desktop"] = "server" + desktop_stdout_events: bool = False + desktop_state_dir_override: str = "" + logs_dir_override: str = "" @property def oauth_base_url(self) -> str: @@ -138,5 +143,21 @@ def oauth_base_url(self) -> str: def cors_origins_list(self) -> list[str]: return [o.strip() for o in self.cors_origins.split(",")] + @property + def is_desktop_runtime(self) -> bool: + return self.runtime_profile == "desktop" + + @property + def desktop_state_dir(self) -> Path: + if self.desktop_state_dir_override.strip(): + return Path(self.desktop_state_dir_override).expanduser().resolve() + return (Path.home() / ".lyranote" / "desktop").resolve() + + @property + def logs_dir(self) -> Path: + if self.logs_dir_override.strip(): + return Path(self.logs_dir_override).expanduser().resolve() + return (Path(__file__).resolve().parent.parent / "logs").resolve() + settings = Settings() diff --git a/apps/api/app/database.py b/apps/api/app/database.py index 05379f4..d8cf723 100644 --- a/apps/api/app/database.py +++ b/apps/api/app/database.py @@ -1,5 +1,10 @@ +import logging +from collections.abc import Callable +from typing import Any + +from sqlalchemy import event from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, Session from app.config import settings @@ -18,11 +23,45 @@ expire_on_commit=False, ) +logger = logging.getLogger(__name__) +_AFTER_COMMIT_CALLBACKS_KEY = "_after_commit_callbacks" + class Base(DeclarativeBase): pass +def enqueue_after_commit(session: AsyncSession, callback: Callable[[], Any]) -> None: + """ + Queue a synchronous callback to run after the current SQLAlchemy transaction commits. + + This is primarily used for side effects like Celery task dispatch, where running the + side effect before commit can race the worker reading rows that are not yet visible. + Falls back to immediate execution for test doubles that do not expose a sync session. + """ + sync_session = getattr(session, "sync_session", None) + info = getattr(sync_session, "info", None) + if info is None: + callback() + return + info.setdefault(_AFTER_COMMIT_CALLBACKS_KEY, []).append(callback) + + +@event.listens_for(Session, "after_commit") +def _run_after_commit_callbacks(session: Session) -> None: + callbacks = session.info.pop(_AFTER_COMMIT_CALLBACKS_KEY, []) + for callback in callbacks: + try: + callback() + except Exception: + logger.exception("after-commit callback failed") + + +@event.listens_for(Session, "after_rollback") +def _clear_after_commit_callbacks(session: Session) -> None: + session.info.pop(_AFTER_COMMIT_CALLBACKS_KEY, None) + + async def get_db() -> AsyncSession: async with AsyncSessionLocal() as session: try: diff --git a/apps/api/app/desktop_main.py b/apps/api/app/desktop_main.py new file mode 100644 index 0000000..35a6704 --- /dev/null +++ b/apps/api/app/desktop_main.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +import uvicorn + + +def desktop_state_dir() -> Path: + override = os.environ.get("DESKTOP_STATE_DIR_OVERRIDE", "").strip() + state_dir = Path(override).expanduser() if override else Path.home() / ".lyranote" / "desktop" + state_dir = state_dir.resolve() + state_dir.mkdir(parents=True, exist_ok=True) + return state_dir + + +def configure_desktop_environment() -> None: + state_dir = desktop_state_dir() + + os.environ.setdefault("RUNTIME_PROFILE", "desktop") + os.environ.setdefault("DESKTOP_STDOUT_EVENTS", "true") + os.environ.setdefault("MEMORY_MODE", "desktop") + os.environ.setdefault("MONITORING_ENABLED", "false") + os.environ.setdefault("DATABASE_URL", f"sqlite+aiosqlite:///{state_dir / 'runtime-api.sqlite3'}") + os.environ.setdefault("STORAGE_BACKEND", "local") + os.environ.setdefault("STORAGE_LOCAL_PATH", str(state_dir / "storage")) + os.environ.setdefault("MEMORY_DIR", str(state_dir / "memory")) + os.environ.setdefault( + "CORS_ORIGINS", + "http://tauri.localhost,tauri://localhost,http://localhost:1420,http://127.0.0.1:1420", + ) + os.environ.setdefault("FRONTEND_URL", "http://tauri.localhost") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the LyraNote desktop sidecar API.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + + configure_desktop_environment() + + uvicorn.run("app.main:app", host=args.host, port=args.port, reload=False) + + +if __name__ == "__main__": + main() diff --git a/apps/api/app/domains/activity/router.py b/apps/api/app/domains/activity/router.py index 0462f40..5f0ad9d 100644 --- a/apps/api/app/domains/activity/router.py +++ b/apps/api/app/domains/activity/router.py @@ -36,6 +36,10 @@ class ActivitySnapshot(BaseModel): note_title: str | None = None editor_word_count: int | None = None active_source_id: str | None = None + copilot_open: bool = False + is_mobile: bool = False + typing_recently: bool = False + last_interaction_ms: int | None = None timestamp_ms: int | None = None diff --git a/apps/api/app/domains/ai/routers/research.py b/apps/api/app/domains/ai/routers/research.py index c81af0a..a80a886 100644 --- a/apps/api/app/domains/ai/routers/research.py +++ b/apps/api/app/domains/ai/routers/research.py @@ -17,6 +17,7 @@ from app.dependencies import CurrentUser, DbDep from app.domains.ai.schemas import ( ClarifyRequest, + DeepResearchPlanRequest, DeepResearchRequest, SaveDeepResearchSourcesRequest, ) @@ -45,6 +46,37 @@ async def clarify_deep_research( return success({"questions": questions}) +@router.post("/ai/deep-research/plan") +async def plan_deep_research( + body: DeepResearchPlanRequest, + current_user: CurrentUser, +): + """Generate a research plan synchronously (no task creation, no SSE). + Returns sub_questions, research_goal, evaluation_criteria, report_title. + """ + from app.agents.research.deep_research import _plan, MODES + from app.providers.llm import get_client + + client = get_client() + cfg = MODES.get(body.mode, MODES["quick"]) + result = await _plan( + body.query, + client, + settings.llm_model, + queries_per_dim=cfg.queries_per_dim, + clarification_context=body.clarification_context, + ) + matrix = result.get("search_matrix", {}) + sub_questions = [q for qs in matrix.values() for q in qs] + return success({ + "sub_questions": sub_questions, + "search_matrix": matrix, + "research_goal": result.get("research_goal", ""), + "evaluation_criteria": result.get("evaluation_criteria", []), + "report_title": result.get("title", body.query[:25]), + }) + + @router.post("/ai/deep-research") async def create_deep_research( body: DeepResearchRequest, @@ -101,9 +133,19 @@ async def create_deep_research( db.add(task) await db.commit() - from app.agents.memory import build_memory_context + from app.agents.memory import load_prompt_context try: - user_memories = await build_memory_context(current_user.id, body.query, db, top_k=5) + prompt_context = await load_prompt_context( + user_id=current_user.id, + query=body.query, + db=db, + scene="research", + notebook_id=notebook_id_uuid, + conversation_id=conv.id, + include_portrait=False, + top_k=5, + ) + user_memories = prompt_context.all_memories except Exception as exc: logger.warning("Memory context load failed: %s", exc) user_memories = [] @@ -120,6 +162,7 @@ async def create_deep_research( tavily_api_key=settings.tavily_api_key or None, user_memories=user_memories, clarification_context=body.clarification_context, + plan_override=body.plan_override.model_dump() if body.plan_override else None, trace_id=get_trace_id(), ) ) diff --git a/apps/api/app/domains/ai/schemas.py b/apps/api/app/domains/ai/schemas.py index 59b2fd1..f643967 100644 --- a/apps/api/app/domains/ai/schemas.py +++ b/apps/api/app/domains/ai/schemas.py @@ -47,11 +47,25 @@ class ClarifyResponse(BaseModel): questions: list[ClarifyQuestion] +class DeepResearchPlanRequest(BaseModel): + query: str + mode: Literal["quick", "deep"] = "quick" + clarification_context: list[dict] | None = None + + +class DeepResearchPlanResponse(BaseModel): + sub_questions: list[str] + research_goal: str + evaluation_criteria: list[str] + report_title: str + + class DeepResearchRequest(BaseModel): query: str notebook_id: str | None = None mode: Literal["quick", "deep"] = "quick" clarification_context: list[dict] | None = None + plan_override: DeepResearchPlanResponse | None = None class SaveDeepResearchSourcesRequest(BaseModel): diff --git a/apps/api/app/domains/config/router.py b/apps/api/app/domains/config/router.py index aaeb540..0d1e8a9 100644 --- a/apps/api/app/domains/config/router.py +++ b/apps/api/app/domains/config/router.py @@ -3,391 +3,75 @@ Allows the settings UI to read/write AI, storage, and personality config without going through the setup wizard. """ + from __future__ import annotations from fastapi import APIRouter, status -from sqlalchemy import select -from app.config import settings as app_settings from app.dependencies import CurrentUser, DbDep from app.exceptions import BadRequestError -from app.models import AppConfig from app.schemas.response import ApiResponse, success +from app.services.config_service import ConfigService, EDITABLE_KEYS -from .schemas import ConfigOut, ConfigPatchRequest, TestEmailResult, TestEmbeddingResult, TestLlmResult, TestRerankerResult +from .schemas import ( + ConfigOut, + ConfigPatchRequest, + TestEmailResult, + TestEmbeddingResult, + TestLlmResult, + TestRerankerResult, +) router = APIRouter(tags=["config"]) -# Keys that can be read/written via this endpoint (mirrors setup RUNTIME_CONFIG_KEYS) -EDITABLE_KEYS = { - # AI — LLM - "llm_provider", - "openai_api_key", - "openai_base_url", - "llm_model", - # AI — Utility model (optional small/fast model for utility tasks) - "llm_utility_model", - "llm_utility_api_key", - "llm_utility_base_url", - # AI — Embedding - "embedding_model", - "embedding_api_key", - "embedding_base_url", - # AI — Reranker (optional, Cross-Encoder) - "reranker_api_key", - "reranker_model", - "reranker_base_url", - # AI — Search - "tavily_api_key", - "perplexity_api_key", - # AI — Image generation (avatar for public home page) - "image_gen_api_key", - "image_gen_base_url", - "image_gen_model", - # Storage - "storage_backend", - "storage_region", - "storage_s3_endpoint_url", - "storage_s3_public_url", - "storage_s3_bucket", - "storage_s3_access_key", - "storage_s3_secret_key", - # Personality - "ai_name", - "user_occupation", - "user_preferences", - "custom_system_prompt", - # Notify / SMTP - "notify_email", - "smtp_host", - "smtp_port", - "smtp_username", - "smtp_password", - "smtp_from", -} - -# Keys whose values should be masked when reading (shown as placeholder) -_SENSITIVE_KEYS = { - "openai_api_key", - "llm_utility_api_key", - "embedding_api_key", - "reranker_api_key", - "storage_s3_access_key", - "storage_s3_secret_key", - "tavily_api_key", - "perplexity_api_key", - "image_gen_api_key", - "smtp_password", -} - @router.get("/config", response_model=ApiResponse[ConfigOut]) async def get_config(_current_user: CurrentUser, db: DbDep): """Return all editable runtime config values. Sensitive keys are masked.""" - result = await db.execute( - select(AppConfig).where(AppConfig.key.in_(EDITABLE_KEYS)) - ) - rows = result.scalars().all() - config: dict[str, str | None] = {key: None for key in EDITABLE_KEYS} - for row in rows: - if row.key in _SENSITIVE_KEYS and row.value: - config[row.key] = "••••••••" - else: - config[row.key] = row.value + config = await ConfigService(db).get_runtime_config() return success(ConfigOut(data=config)) @router.patch("/config", status_code=status.HTTP_204_NO_CONTENT) async def update_config(body: ConfigPatchRequest, _current_user: CurrentUser, db: DbDep): """Batch-update runtime config. Only keys in EDITABLE_KEYS are accepted.""" - from app.config import settings - unknown = set(body.data.keys()) - EDITABLE_KEYS if unknown: raise BadRequestError(f"未知的配置键:{', '.join(sorted(unknown))}") - for key, value in body.data.items(): - str_value = str(value) if value is not None else "" - # Skip masked placeholder — don't overwrite with the mask string - if str_value == "••••••••": - continue - - result = await db.execute(select(AppConfig).where(AppConfig.key == key)) - row = result.scalar_one_or_none() - if row: - row.value = str_value - else: - db.add(AppConfig(key=key, value=str_value)) - - # Sync to in-memory settings immediately (non-critical) - if str_value: - try: - setattr(settings, key, str_value) - except Exception: - pass - - await db.commit() - - # Reset LLM provider singleton when provider-related keys change - provider_keys = {"openai_api_key", "openai_base_url", "llm_model", "llm_provider"} - if provider_keys & set(body.data.keys()): - from app.providers.provider_factory import reset_provider - reset_provider() - - # Reset embedding client when embedding-related keys change - embedding_keys = {"openai_api_key", "openai_base_url", "embedding_api_key", "embedding_base_url", "embedding_model"} - if embedding_keys & set(body.data.keys()): - try: - from app.providers import embedding - embedding._client = None - except Exception: - pass - - # Reset reranker client when reranker-related keys change - reranker_keys = {"reranker_api_key", "reranker_model", "reranker_base_url"} - if reranker_keys & set(body.data.keys()): - try: - from app.providers import reranker - reranker._client = None # type: ignore[attr-defined] - except Exception: - pass + await ConfigService(db).update_runtime_config(body.data) @router.post("/config/test-email", response_model=ApiResponse[TestEmailResult]) async def test_email(_current_user: CurrentUser, db: DbDep): """Send a real test email using the current SMTP configuration.""" - from app.providers.email import send_email - - result = await db.execute( - select(AppConfig).where( - AppConfig.key.in_({"notify_email", "smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_from"}) - ) - ) - cfg = {r.key: r.value for r in result.scalars().all()} - - to = cfg.get("notify_email", "") - if not to: - return success(TestEmailResult(ok=False, message="未设置通知邮箱地址")) - - if not cfg.get("smtp_host") or not cfg.get("smtp_username"): - return success(TestEmailResult(ok=False, message="SMTP 未配置完整")) - - html = """
-

LyraNote 测试邮件

-

如果你收到了这封邮件,说明 SMTP 配置正确,邮件功能已可正常使用。

-

— LyraNote

-
""" - - result = await send_email( - to=to, - subject="LyraNote 测试邮件", - html_body=html, - text_body="如果你收到了这封邮件,说明 SMTP 配置正确。", - smtp_config=cfg, - ) - - if result.ok: - return success(TestEmailResult(ok=True, message=f"测试邮件已发送至 {to}")) - if result.error: - return success(TestEmailResult(ok=False, message=f"发送失败:{result.error}")) - return success(TestEmailResult(ok=False, message="发送失败,请检查 SMTP 配置")) + result = await ConfigService(db).test_email() + return success(TestEmailResult(**result)) @router.post("/config/test-llm", response_model=ApiResponse[TestLlmResult]) async def test_llm_connection(_current_user: CurrentUser, db: DbDep): """Send a minimal request to the configured LLM to verify connectivity.""" - result = await db.execute(select(AppConfig).where(AppConfig.key.in_({"llm_provider", "openai_api_key", "openai_base_url", "llm_model"}))) - rows = {r.key: r.value for r in result.scalars().all()} - - provider = rows.get("llm_provider") or app_settings.llm_provider or "openai" - api_key = rows.get("openai_api_key") or app_settings.openai_api_key - base_url = rows.get("openai_base_url") or app_settings.openai_base_url or None - model = rows.get("llm_model") or app_settings.llm_model - - if not api_key: - return success(TestLlmResult(ok=False, model=model, message="未设置 API Key")) - - try: - if provider == "litellm": - import litellm - call_kw: dict = dict( - model=model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - api_key=api_key, - drop_params=True, - ) - if model.startswith("gemini/"): - call_kw["custom_llm_provider"] = "gemini" - if base_url: - call_kw["api_base"] = base_url - resp = await litellm.acompletion(**call_kw) - reply = (resp.choices[0].message.content or "").strip() - else: - from openai import AsyncOpenAI - client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=15.0) - resp = await client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - ) - reply = (resp.choices[0].message.content or "").strip() - return success(TestLlmResult(ok=True, model=model, message=reply or "OK")) - except Exception as exc: - return success(TestLlmResult(ok=False, model=model, message=str(exc)[:200])) + result = await ConfigService(db).test_saved_llm_connection() + return success(TestLlmResult(**result)) @router.post("/config/test-utility-llm", response_model=ApiResponse[TestLlmResult]) async def test_utility_llm_connection(_current_user: CurrentUser, db: DbDep): """Test the utility (small) model using its own config, falling back to main model config.""" - result = await db.execute(select(AppConfig).where(AppConfig.key.in_({ - "llm_provider", "openai_api_key", "openai_base_url", - "llm_utility_model", "llm_utility_api_key", "llm_utility_base_url", - }))) - rows = {r.key: r.value for r in result.scalars().all()} - - utility_model = rows.get("llm_utility_model") or app_settings.llm_utility_model - if not utility_model: - return success(TestLlmResult(ok=False, model="", message="未配置小模型")) - - api_key = rows.get("llm_utility_api_key") or app_settings.llm_utility_api_key \ - or rows.get("openai_api_key") or app_settings.openai_api_key - base_url = rows.get("llm_utility_base_url") or app_settings.llm_utility_base_url \ - or rows.get("openai_base_url") or app_settings.openai_base_url or None - provider = rows.get("llm_provider") or app_settings.llm_provider or "openai" - - if not api_key: - return success(TestLlmResult(ok=False, model=utility_model, message="未设置 API Key")) - - try: - if provider == "litellm" or "/" in utility_model: - import litellm - call_kw: dict = dict( - model=utility_model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - api_key=api_key, - drop_params=True, - ) - if utility_model.startswith("gemini/"): - call_kw["custom_llm_provider"] = "gemini" - if base_url: - call_kw["api_base"] = base_url - resp = await litellm.acompletion(**call_kw) - reply = (resp.choices[0].message.content or "").strip() - else: - from openai import AsyncOpenAI - client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=15.0) - resp = await client.chat.completions.create( - model=utility_model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - ) - reply = (resp.choices[0].message.content or "").strip() - return success(TestLlmResult(ok=True, model=utility_model, message=reply or "OK")) - except Exception as exc: - return success(TestLlmResult(ok=False, model=utility_model, message=str(exc)[:200])) - - provider = rows.get("llm_provider") or app_settings.llm_provider or "openai" - api_key = rows.get("openai_api_key") or app_settings.openai_api_key - base_url = rows.get("openai_base_url") or app_settings.openai_base_url or None - model = rows.get("llm_model") or app_settings.llm_model - - if not api_key: - return success(TestLlmResult(ok=False, model=model, message="未设置 API Key")) - - try: - if provider == "litellm": - import litellm - call_kw: dict = dict( - model=model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - api_key=api_key, - drop_params=True, - ) - if model.startswith("gemini/"): - call_kw["custom_llm_provider"] = "gemini" - if base_url: - call_kw["api_base"] = base_url - resp = await litellm.acompletion(**call_kw) - reply = (resp.choices[0].message.content or "").strip() - else: - from openai import AsyncOpenAI - client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=15.0) - resp = await client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - ) - reply = (resp.choices[0].message.content or "").strip() - return success(TestLlmResult(ok=True, model=model, message=reply or "OK")) - except Exception as exc: - return success(TestLlmResult(ok=False, model=model, message=str(exc)[:200])) + result = await ConfigService(db).test_saved_utility_llm_connection() + return success(TestLlmResult(**result)) @router.post("/config/test-embedding", response_model=ApiResponse[TestEmbeddingResult]) async def test_embedding_connection(_current_user: CurrentUser, db: DbDep): """Test the configured Embedding API by creating a short vector.""" - from openai import AsyncOpenAI - - result = await db.execute( - select(AppConfig).where(AppConfig.key.in_( - {"openai_api_key", "openai_base_url", "embedding_model", "embedding_api_key", "embedding_base_url"} - )) - ) - rows = {r.key: r.value for r in result.scalars().all()} - - api_key = rows.get("embedding_api_key") or rows.get("openai_api_key") or app_settings.embedding_api_key or app_settings.openai_api_key - base_url = rows.get("embedding_base_url") or rows.get("openai_base_url") or app_settings.embedding_base_url or app_settings.openai_base_url or None - model = rows.get("embedding_model") or app_settings.embedding_model - - if not api_key: - return success(TestEmbeddingResult(ok=False, model=model, dimensions=0, message="未设置 API Key")) - - client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=15.0) - try: - resp = await client.embeddings.create(model=model, input=["test"]) - dims = len(resp.data[0].embedding) - return success(TestEmbeddingResult(ok=True, model=model, dimensions=dims, message=f"维度 {dims}")) - except Exception as exc: - return success(TestEmbeddingResult(ok=False, model=model, dimensions=0, message=str(exc)[:200])) + result = await ConfigService(db).test_saved_embedding_connection() + return success(TestEmbeddingResult(**result)) @router.post("/config/test-reranker", response_model=ApiResponse[TestRerankerResult]) async def test_reranker_connection(_current_user: CurrentUser, db: DbDep): """Test the configured Reranker API with a minimal request.""" - import httpx - - result = await db.execute( - select(AppConfig).where(AppConfig.key.in_( - {"openai_api_key", "openai_base_url", "reranker_api_key", "reranker_base_url", "reranker_model"} - )) - ) - rows = {r.key: r.value for r in result.scalars().all()} - - api_key = rows.get("reranker_api_key") or rows.get("openai_api_key") or app_settings.reranker_api_key or app_settings.openai_api_key - base_url = (rows.get("reranker_base_url") or rows.get("openai_base_url") or app_settings.reranker_base_url or app_settings.openai_base_url or "").rstrip("/") - model = rows.get("reranker_model") or app_settings.reranker_model - - if not api_key: - return success(TestRerankerResult(ok=False, model=model, message="未设置 API Key")) - if not base_url: - return success(TestRerankerResult(ok=False, model=model, message="未设置 Base URL")) - - try: - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post( - f"{base_url}/rerank", - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - json={"model": model, "query": "test", "documents": ["hello world"], "top_n": 1, "return_documents": False}, - ) - resp.raise_for_status() - data = resp.json() - results = data.get("results", []) - if results: - score = round(results[0].get("relevance_score", 0), 4) - return success(TestRerankerResult(ok=True, model=model, message=f"Score {score}")) - return success(TestRerankerResult(ok=True, model=model, message="OK")) - except Exception as exc: - return success(TestRerankerResult(ok=False, model=model, message=str(exc)[:200])) + result = await ConfigService(db).test_saved_reranker_connection() + return success(TestRerankerResult(**result)) diff --git a/apps/api/app/domains/conversation/router.py b/apps/api/app/domains/conversation/router.py index 36ca443..7f12c2d 100644 --- a/apps/api/app/domains/conversation/router.py +++ b/apps/api/app/domains/conversation/router.py @@ -197,6 +197,19 @@ async def subscribe_message_generation( ) +@router.delete( + "/messages/generations/{generation_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def cancel_message_generation( + generation_id: UUID, + db: DbDep, + current_user: CurrentUser, +): + svc = ConversationService(db, current_user.id) + await svc.cancel_message_generation(generation_id) + + @router.delete( "/conversations/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/apps/api/app/domains/desktop/router.py b/apps/api/app/domains/desktop/router.py new file mode 100644 index 0000000..2dbf1f2 --- /dev/null +++ b/apps/api/app/domains/desktop/router.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from fastapi import APIRouter, Query, status + +from app.dependencies import CurrentUser, DbDep +from app.schemas.response import ApiResponse, success +from app.services.desktop_chat_service import DesktopChatService +from app.services.desktop_knowledge_service import DesktopKnowledgeService +from app.services.desktop_service import DesktopService + +from .schemas import ( + CancelJobResult, + CreateWatchFolderRequest, + DeleteWatchFolderRequest, + DesktopJobListOut, + DesktopRuntimeOut, + InspectLocalFileRequest, + InspectLocalFileResult, + ImportWatchFolderRequest, + ImportWatchFolderResult, + RecentImportListOut, + LocalAnswerOut, + LocalAnswerRequest, + LocalSearchOut, + WatchFolderListOut, + WatchFolderOut, +) + +router = APIRouter(tags=["desktop"]) +service = DesktopService() + + +@router.get("/desktop/runtime", response_model=ApiResponse[DesktopRuntimeOut]) +async def get_desktop_runtime(): + return success(DesktopRuntimeOut(**service.get_runtime_status())) + + +@router.get("/jobs", response_model=ApiResponse[DesktopJobListOut]) +async def list_jobs(current_user: CurrentUser): + return success(DesktopJobListOut(**service.list_jobs(user_id=str(current_user.id)))) + + +@router.post("/jobs/{job_id}/cancel", response_model=ApiResponse[CancelJobResult], status_code=status.HTTP_200_OK) +async def cancel_job(job_id: str, current_user: CurrentUser): + return success(CancelJobResult(**service.cancel_job(user_id=str(current_user.id), job_id=job_id))) + + +@router.get("/watch-folders", response_model=ApiResponse[WatchFolderListOut]) +async def list_watch_folders(current_user: CurrentUser): + return success(WatchFolderListOut(**service.list_watch_folders(user_id=str(current_user.id)))) + + +@router.get("/recent-imports", response_model=ApiResponse[RecentImportListOut]) +async def list_recent_imports(current_user: CurrentUser): + return success(RecentImportListOut(**service.list_recent_imports(user_id=str(current_user.id)))) + + +@router.post("/local-files/inspect", response_model=ApiResponse[InspectLocalFileResult], status_code=status.HTTP_200_OK) +async def inspect_local_file(body: InspectLocalFileRequest, current_user: CurrentUser): + return success( + InspectLocalFileResult( + **service.inspect_local_file( + user_id=str(current_user.id), + path=body.path, + sha256=body.sha256, + ) + ) + ) + + +@router.get("/search/local", response_model=ApiResponse[LocalSearchOut]) +async def search_local( + db: DbDep, + current_user: CurrentUser, + q: str = Query(..., min_length=1), + notebook_id: str | None = Query(None), + source_id: str | None = Query(None), + limit: int = Query(5, ge=1, le=20), +): + service = DesktopKnowledgeService(db, current_user.id) + return success( + LocalSearchOut( + **await service.search_local( + query=q, + notebook_id=notebook_id, + source_id=source_id, + limit=limit, + ) + ) + ) + + +@router.post( + "/desktop/chat/local-answer", + response_model=ApiResponse[LocalAnswerOut], + status_code=status.HTTP_200_OK, +) +async def answer_locally( + body: LocalAnswerRequest, + db: DbDep, + current_user: CurrentUser, +): + service = DesktopChatService(db, current_user.id) + return success( + LocalAnswerOut( + **await service.answer_locally( + query=body.query, + notebook_id=body.notebook_id, + source_id=body.source_id, + limit=body.limit, + ) + ) + ) + + +@router.post("/watch-folders", response_model=ApiResponse[WatchFolderOut], status_code=status.HTTP_201_CREATED) +async def create_watch_folder(body: CreateWatchFolderRequest, current_user: CurrentUser): + return success(WatchFolderOut(**service.create_watch_folder(user_id=str(current_user.id), path=body.path))) + + +@router.delete("/watch-folders", status_code=status.HTTP_204_NO_CONTENT) +async def delete_watch_folder(body: DeleteWatchFolderRequest, current_user: CurrentUser): + service.delete_watch_folder(user_id=str(current_user.id), folder_id=body.id) + + +@router.post("/watch-folders/import", response_model=ApiResponse[ImportWatchFolderResult], status_code=status.HTTP_200_OK) +async def import_watch_folder_path(body: ImportWatchFolderRequest, current_user: CurrentUser): + return success( + ImportWatchFolderResult( + **await service.import_watch_folder_path( + user_id=str(current_user.id), + path=body.path, + ) + ) + ) diff --git a/apps/api/app/domains/desktop/schemas.py b/apps/api/app/domains/desktop/schemas.py new file mode 100644 index 0000000..82c9cfc --- /dev/null +++ b/apps/api/app/domains/desktop/schemas.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class DesktopRuntimeOut(BaseModel): + profile: str + health: str + database_url: str + memory_mode: str + memory_dir: str + stdout_events: bool + + +class DesktopJobOut(BaseModel): + id: str + kind: str + state: str + label: str + progress: int = 0 + message: str | None = None + resource_id: str | None = None + created_at: str + updated_at: str + + +class DesktopJobListOut(BaseModel): + items: list[DesktopJobOut] = Field(default_factory=list) + + +class CancelJobResult(BaseModel): + cancelled: bool + reason: str | None = None + + +class WatchFolderOut(BaseModel): + id: str + path: str + name: str + created_at: str + last_synced_at: str | None = None + last_error: str | None = None + is_active: bool = True + + +class CreateWatchFolderRequest(BaseModel): + path: str + + +class DeleteWatchFolderRequest(BaseModel): + id: str + + +class WatchFolderListOut(BaseModel): + items: list[WatchFolderOut] = Field(default_factory=list) + + +class RecentImportOut(BaseModel): + path: str + source_id: str | None = None + title: str | None = None + imported_at: str + + +class RecentImportListOut(BaseModel): + items: list[RecentImportOut] = Field(default_factory=list) + + +class ImportWatchFolderRequest(BaseModel): + path: str + + +class ImportWatchFolderResult(BaseModel): + state: str + path: str + source_id: str | None = None + + +class InspectLocalFileRequest(BaseModel): + path: str + sha256: str | None = None + + +class InspectLocalFileResult(BaseModel): + state: str + path: str + source_id: str | None = None + matched_path: str | None = None + matched_title: str | None = None + sha256: str | None = None + + +class LocalSearchHitOut(BaseModel): + chunk_id: str + source_id: str + notebook_id: str + source_title: str | None = None + source_type: str + chunk_index: int + content: str + excerpt: str + rank: float | None = None + metadata: dict | None = None + + +class LocalSearchOut(BaseModel): + query: str + mode: str + items: list[LocalSearchHitOut] = Field(default_factory=list) + + +class LocalAnswerRequest(BaseModel): + query: str + notebook_id: str | None = None + source_id: str | None = None + limit: int = Field(default=5, ge=1, le=20) + + +class LocalAnswerCitationOut(BaseModel): + source_id: str + chunk_id: str + source_title: str | None = None + excerpt: str + metadata: dict | None = None + + +class LocalAnswerOut(BaseModel): + mode: str + query: str + answer: str + citations: list[LocalAnswerCitationOut] = Field(default_factory=list) diff --git a/apps/api/app/domains/knowledge/schemas.py b/apps/api/app/domains/knowledge/schemas.py index 68925a4..10090a2 100644 --- a/apps/api/app/domains/knowledge/schemas.py +++ b/apps/api/app/domains/knowledge/schemas.py @@ -17,7 +17,7 @@ class SuggestResponse(BaseModel): class RewriteRequest(BaseModel): selected_text: str - action: Literal["polish", "shorten", "expand"] + action: Literal["polish", "proofread", "reformat", "shorten", "expand"] note_context: str = "" diff --git a/apps/api/app/domains/memory/router.py b/apps/api/app/domains/memory/router.py index 787dd0c..afb3a21 100644 --- a/apps/api/app/domains/memory/router.py +++ b/apps/api/app/domains/memory/router.py @@ -8,6 +8,7 @@ PUT /memory/{id} Correct a memory value DELETE /memory/{id} Delete a memory entry POST /memory/reset Delete all memories (GDPR-friendly) + POST /memory/backfill Re-extract memories from all existing conversations """ from uuid import UUID @@ -64,12 +65,13 @@ async def get_memory_doc_endpoint(_current_user: CurrentUser) -> ApiResponse[Mem @router.patch("/memory/doc", status_code=status.HTTP_204_NO_CONTENT) async def update_memory_doc_endpoint( body: MemoryDocUpdate, - _current_user: CurrentUser, + current_user: CurrentUser, + db: DbDep, ) -> None: - """Overwrite the global AI memory document (write to local file).""" - import asyncio - from app.agents.memory.file_storage import write_memory_doc - await asyncio.to_thread(write_memory_doc, body.content_md) + """Overwrite the global AI memory document and sync it to structured memory.""" + from app.services.memory_service import MemoryService + + await MemoryService(db, current_user.id).update_memory_doc(body.content_md) # --------------------------------------------------------------------------- @@ -232,6 +234,41 @@ async def reset_memories(db: DbDep, current_user: CurrentUser) -> None: await db.delete(memory) +@router.post("/memory/backfill", response_model=ApiResponse[dict]) +async def backfill_memories(db: DbDep, current_user: CurrentUser) -> ApiResponse[dict]: + """Re-run memory extraction over all existing conversations for the current user. + + Useful when a user's conversation history predates the extraction pipeline, + or when extraction silently failed for earlier conversations. + """ + import asyncio + + from app.models import Conversation + + result = await db.execute( + select(Conversation.id).where(Conversation.user_id == current_user.id) + ) + conversation_ids = [row[0] for row in result.all()] + + if not conversation_ids: + return success({"queued": 0, "message": "没有可处理的对话"}) + + async def _run_backfill() -> None: + from app.agents.memory import extract_memories + from app.database import AsyncSessionLocal + + for conv_id in conversation_ids: + try: + async with AsyncSessionLocal() as session: + await extract_memories(conv_id, current_user.id, session) + await session.commit() + except Exception: + pass + + asyncio.create_task(_run_backfill()) + return success({"queued": len(conversation_ids), "message": f"已开始处理 {len(conversation_ids)} 条对话"}) + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- diff --git a/apps/api/app/domains/monitoring/router.py b/apps/api/app/domains/monitoring/router.py index 56405bc..1f97a98 100644 --- a/apps/api/app/domains/monitoring/router.py +++ b/apps/api/app/domains/monitoring/router.py @@ -1,5 +1,7 @@ from __future__ import annotations +from uuid import UUID + from fastapi import APIRouter, Query from app.dependencies import CurrentUser, DbDep @@ -38,11 +40,29 @@ async def list_monitoring_traces( type: str | None = Query(None), status: str | None = Query(None), cursor: str | None = Query(None), + user_id: UUID | None = Query(None), + conversation_id: UUID | None = Query(None), + generation_id: UUID | None = Query(None), + task_id: UUID | None = Query(None), + task_run_id: UUID | None = Query(None), + notebook_id: UUID | None = Query(None), limit: int = Query(20, ge=1, le=100), ): _ = current_user service = MonitoringService(db) - return success(await service.list_traces(window=window, run_type=type, status=status, cursor=cursor, limit=limit)) + return success(await service.list_traces( + window=window, + run_type=type, + status=status, + cursor=cursor, + user_id=user_id, + conversation_id=conversation_id, + generation_id=generation_id, + task_id=task_id, + task_run_id=task_run_id, + notebook_id=notebook_id, + limit=limit, + )) @router.get("/monitoring/traces/{trace_id}", response_model=ApiResponse[TraceDetailOut]) @@ -65,10 +85,25 @@ async def list_monitoring_failures( db: DbDep, window: str = Query("24h"), kind: str | None = Query(None), + user_id: UUID | None = Query(None), + conversation_id: UUID | None = Query(None), + generation_id: UUID | None = Query(None), + task_id: UUID | None = Query(None), + task_run_id: UUID | None = Query(None), + notebook_id: UUID | None = Query(None), ): _ = current_user service = MonitoringService(db) - return success(await service.list_failures(window=window, kind=kind)) + return success(await service.list_failures( + window=window, + kind=kind, + user_id=user_id, + conversation_id=conversation_id, + generation_id=generation_id, + task_id=task_id, + task_run_id=task_run_id, + notebook_id=notebook_id, + )) @router.get("/monitoring/workers", response_model=ApiResponse[list[WorkerHeartbeatOut]]) @@ -87,9 +122,26 @@ async def list_monitoring_workloads( db: DbDep, kind: str | None = Query(None), status: str | None = Query(None), + user_id: UUID | None = Query(None), + conversation_id: UUID | None = Query(None), + generation_id: UUID | None = Query(None), + task_id: UUID | None = Query(None), + task_run_id: UUID | None = Query(None), + notebook_id: UUID | None = Query(None), offset: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), ): _ = current_user service = MonitoringService(db) - return success(await service.list_workloads(kind=kind, status=status, offset=offset, limit=limit)) + return success(await service.list_workloads( + kind=kind, + status=status, + user_id=user_id, + conversation_id=conversation_id, + generation_id=generation_id, + task_id=task_id, + task_run_id=task_run_id, + notebook_id=notebook_id, + offset=offset, + limit=limit, + )) diff --git a/apps/api/app/domains/monitoring/schemas.py b/apps/api/app/domains/monitoring/schemas.py index 7db3509..3c87017 100644 --- a/apps/api/app/domains/monitoring/schemas.py +++ b/apps/api/app/domains/monitoring/schemas.py @@ -57,8 +57,11 @@ class TraceRunOut(BaseModel): class TraceSpanOut(BaseModel): id: str run_id: str + parent_span_id: str | None = None trace_id: str span_name: str + component: str | None = None + span_kind: str | None = None status: str duration_ms: int | None = None error_message: str | None = None @@ -138,6 +141,8 @@ class FailureItemOut(BaseModel): status: str message: str | None = None trace_id: str | None = None + trace_available: bool = False + trace_missing_reason: str | None = None title: str | None = None conversation_id: str | None = None notebook_id: str | None = None @@ -169,6 +174,8 @@ class WorkloadItemOut(BaseModel): kind: str id: str trace_id: str | None = None + trace_available: bool = False + trace_missing_reason: str | None = None status: str started_at: str finished_at: str | None = None diff --git a/apps/api/app/domains/note/router.py b/apps/api/app/domains/note/router.py index f09e69c..3976cac 100644 --- a/apps/api/app/domains/note/router.py +++ b/apps/api/app/domains/note/router.py @@ -1,4 +1,5 @@ -import asyncio +import logging +import re from uuid import UUID from fastapi import APIRouter, status @@ -8,17 +9,21 @@ from app.exceptions import NotFoundError from app.models import Notebook, Note from app.schemas.response import ApiResponse, success +from app.utils.async_tasks import create_logged_task from .schemas import NoteCreate, NoteOut, NoteUpdate router = APIRouter(tags=["notes"]) +logger = logging.getLogger(__name__) + +_CHINESE_RE = re.compile(r"[\u4e00-\u9fff]") +_ENGLISH_TOKEN_RE = re.compile(r"[a-zA-Z0-9]+") def _compute_word_count(text: str | None) -> int: if not text: return 0 - import re - chinese = len(re.findall(r'[\u4e00-\u9fff]', text)) - english = len(re.findall(r'[a-zA-Z0-9]+', text)) + chinese = len(_CHINESE_RE.findall(text)) + english = len(_ENGLISH_TOKEN_RE.findall(text)) return chinese + english @@ -29,8 +34,8 @@ def _dispatch_summary(notebook_id: UUID, content_text: str | None) -> None: try: from app.workers.tasks import generate_notebook_summary generate_notebook_summary.delay(str(notebook_id), content_text) - except Exception: - pass # Celery unavailable in dev — skip silently + except Exception as exc: + logger.debug("Notebook summary dispatch skipped: %s", exc) def _dispatch_note_indexing(note_id: UUID) -> None: @@ -38,21 +43,21 @@ def _dispatch_note_indexing(note_id: UUID) -> None: try: from app.workers.tasks import index_note index_note.delay(str(note_id)) - except Exception: - pass + except Exception as exc: + logger.debug("Note indexing dispatch skipped: %s", exc) async def _refresh_notebook_summary_safe(notebook_id: UUID) -> None: """Fire-and-forget: refresh notebook summary from all indexed sources.""" from app.agents.memory import refresh_notebook_summary from app.database import AsyncSessionLocal - import logging + try: async with AsyncSessionLocal() as session: await refresh_notebook_summary(notebook_id, session) await session.commit() except Exception as exc: - logging.getLogger(__name__).debug("Notebook summary refresh skipped: %s", exc) + logger.debug("Notebook summary refresh skipped: %s", exc) @router.get("/notebooks/{notebook_id}/notes", response_model=ApiResponse[list[NoteOut]]) @@ -83,7 +88,11 @@ async def create_note( await db.flush() await db.refresh(note) _dispatch_summary(notebook_id, body.content_text) - asyncio.create_task(_refresh_notebook_summary_safe(notebook_id)) + create_logged_task( + _refresh_notebook_summary_safe(notebook_id), + logger=logger, + description=f"refresh notebook summary {notebook_id}", + ) return success(note) @@ -110,7 +119,11 @@ async def update_note( if "content_text" in updates: _dispatch_summary(note.notebook_id, updates["content_text"]) - asyncio.create_task(_refresh_notebook_summary_safe(note.notebook_id)) + create_logged_task( + _refresh_notebook_summary_safe(note.notebook_id), + logger=logger, + description=f"refresh notebook summary {note.notebook_id}", + ) _dispatch_note_indexing(note.id) return success(note) diff --git a/apps/api/app/domains/notebook/router.py b/apps/api/app/domains/notebook/router.py index ad422f6..2a09238 100644 --- a/apps/api/app/domains/notebook/router.py +++ b/apps/api/app/domains/notebook/router.py @@ -1,193 +1,70 @@ -from datetime import datetime, timezone from uuid import UUID from fastapi import APIRouter, status -from sqlalchemy import delete as sql_delete, func, select -from sqlalchemy.orm import selectinload from app.dependencies import CurrentUser, DbDep -from app.exceptions import NotFoundError -from app.models import Note, Notebook, Source from app.schemas.response import ApiResponse, success -from app.services.public_home_service import refresh_public_home_draft +from app.services import notebook_service from .schemas import NotebookCreate, NotebookOut, NotebookUpdate router = APIRouter(prefix="/notebooks", tags=["notebooks"]) -# ── Helpers ─────────────────────────────────────────────────────────────────── - -def _word_count_subquery(): - return ( - select(func.coalesce(func.sum(Note.word_count), 0)) - .where(Note.notebook_id == Notebook.id) - .correlate(Notebook) - .scalar_subquery() - .label("wc") - ) - - -def _source_count_subquery(): - return ( - select(func.count(Source.id)) - .where(Source.notebook_id == Notebook.id) - .correlate(Notebook) - .scalar_subquery() - .label("src_count") - ) - - -def _note_count_subquery(): - return ( - select(func.count(Note.id)) - .where(Note.notebook_id == Notebook.id) - .correlate(Notebook) - .scalar_subquery() - .label("note_count") - ) - - -def _build_out(nb: Notebook, src_count: int, note_count: int, word_count: int) -> NotebookOut: - nb.source_count = src_count - nb.note_count = note_count - nb.word_count = word_count - nb.summary_md = nb.summary.summary_md if nb.summary else None - return nb - - -async def _fill_counts(db, notebook: Notebook) -> Notebook: - """Used for single-notebook endpoints (create/update/get).""" - src_res = await db.execute( - select(func.count(Source.id)).where(Source.notebook_id == notebook.id) - ) - note_res = await db.execute( - select(func.count(Note.id)).where(Note.notebook_id == notebook.id) - ) - wc_res = await db.execute( - select(func.coalesce(func.sum(Note.word_count), 0)).where(Note.notebook_id == notebook.id) - ) - notebook.source_count = src_res.scalar() or 0 - notebook.note_count = note_res.scalar() or 0 - notebook.word_count = wc_res.scalar() or 0 - notebook.summary_md = notebook.summary.summary_md if notebook.summary else None - return notebook - - -async def _get_or_create_global_notebook(db, user_id) -> Notebook: - result = await db.execute( - select(Notebook) - .options(selectinload(Notebook.summary)) - .where(Notebook.user_id == user_id, Notebook.is_global.is_(True)) - ) - notebook = result.scalar_one_or_none() - if notebook is None: - notebook = Notebook( - user_id=user_id, - title="全局知识库", - description="全局来源,不绑定具体笔记本。", - is_global=True, - is_system=False, - status="active", - ) - db.add(notebook) - await db.flush() - await db.refresh(notebook) - await db.refresh(notebook, attribute_names=["summary"]) - return notebook - - -# ── Routes ──────────────────────────────────────────────────────────────────── - @router.get("", response_model=ApiResponse[list[NotebookOut]]) async def list_notebooks(db: DbDep, current_user: CurrentUser): - result = await db.execute( - select(Notebook, _source_count_subquery(), _note_count_subquery(), _word_count_subquery()) - .options(selectinload(Notebook.summary)) - .where(Notebook.user_id == current_user.id, Notebook.is_global.is_(False)) - .order_by(Notebook.updated_at.desc()) - ) - return success([_build_out(nb, src_cnt, note_cnt, wc) for nb, src_cnt, note_cnt, wc in result.all()]) + notebooks = await notebook_service.list_user_notebooks(db, current_user.id) + return success(notebooks) @router.post("", response_model=ApiResponse[NotebookOut], status_code=status.HTTP_201_CREATED) async def create_notebook(body: NotebookCreate, db: DbDep, current_user: CurrentUser): - notebook = Notebook(user_id=current_user.id, **body.model_dump()) - db.add(notebook) - await db.flush() - await db.refresh(notebook) - await db.refresh(notebook, attribute_names=["summary"]) - nb = await _fill_counts(db, notebook) - out = NotebookOut.model_validate(nb) - out.is_new = True # Signal the frontend to open the import dialog + notebook = await notebook_service.create_notebook( + db, + current_user.id, + body.model_dump(), + ) + out = NotebookOut.model_validate(notebook) + out.is_new = True return success(out) @router.get("/global", response_model=ApiResponse[NotebookOut]) async def get_global_notebook(db: DbDep, current_user: CurrentUser): - notebook = await _get_or_create_global_notebook(db, current_user.id) - return success(await _fill_counts(db, notebook)) + notebook = await notebook_service.get_or_create_global_notebook(db, current_user.id) + return success(notebook) @router.get("/{notebook_id}", response_model=ApiResponse[NotebookOut]) async def get_notebook(notebook_id: UUID, db: DbDep, current_user: CurrentUser): - notebook = await _get_owned(db, notebook_id, current_user.id) - return success(await _fill_counts(db, notebook)) + notebook = await notebook_service.get_notebook_detail(db, notebook_id, current_user.id) + return success(notebook) @router.patch("/{notebook_id}", response_model=ApiResponse[NotebookOut]) async def update_notebook( notebook_id: UUID, body: NotebookUpdate, db: DbDep, current_user: CurrentUser ): - notebook = await _get_owned(db, notebook_id, current_user.id) - for field, value in body.model_dump(exclude_none=True).items(): - setattr(notebook, field, value) - await db.flush() - await db.refresh(notebook) - await db.refresh(notebook, attribute_names=["summary"]) - return success(await _fill_counts(db, notebook)) + notebook = await notebook_service.update_notebook( + db, + notebook_id, + current_user.id, + body.model_dump(exclude_none=True), + ) + return success(notebook) @router.patch("/{notebook_id}/publish", response_model=ApiResponse[NotebookOut]) async def publish_notebook(notebook_id: UUID, db: DbDep, current_user: CurrentUser): - notebook = await _get_owned(db, notebook_id, current_user.id) - notebook.is_public = True - notebook.published_at = datetime.now(timezone.utc) - await db.flush() - await refresh_public_home_draft(db, current_user.id) - await db.refresh(notebook) - await db.refresh(notebook, attribute_names=["summary"]) - return success(await _fill_counts(db, notebook)) + notebook = await notebook_service.publish_notebook(db, notebook_id, current_user.id) + return success(notebook) @router.patch("/{notebook_id}/unpublish", response_model=ApiResponse[NotebookOut]) async def unpublish_notebook(notebook_id: UUID, db: DbDep, current_user: CurrentUser): - notebook = await _get_owned(db, notebook_id, current_user.id) - notebook.is_public = False - await db.flush() - await refresh_public_home_draft(db, current_user.id) - await db.refresh(notebook) - await db.refresh(notebook, attribute_names=["summary"]) - return success(await _fill_counts(db, notebook)) + notebook = await notebook_service.unpublish_notebook(db, notebook_id, current_user.id) + return success(notebook) @router.delete("/{notebook_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_notebook(notebook_id: UUID, db: DbDep, current_user: CurrentUser): - # Verify ownership first (raises 404 if not found / not owned) - await _get_owned(db, notebook_id, current_user.id) - # Use Core DELETE so the DB handles ON DELETE CASCADE itself. - # The ORM path fails because NotebookSummary.notebook_id is both FK and PK — - # SQLAlchemy ORM tries to NULL-out the FK before deleting, which is illegal for a PK. - await db.execute(sql_delete(Notebook).where(Notebook.id == notebook_id)) - await db.commit() # Commit before response so router.refresh() sees the change - - -async def _get_owned(db, notebook_id: UUID, user_id) -> Notebook: - result = await db.execute( - select(Notebook) - .options(selectinload(Notebook.summary)) - .where(Notebook.id == notebook_id, Notebook.user_id == user_id) - ) - notebook = result.scalar_one_or_none() - if notebook is None: - raise NotFoundError("笔记本不存在") - return notebook + await notebook_service.delete_notebook(db, notebook_id, current_user.id) diff --git a/apps/api/app/domains/notebook/schemas.py b/apps/api/app/domains/notebook/schemas.py index babac8f..6745ea5 100644 --- a/apps/api/app/domains/notebook/schemas.py +++ b/apps/api/app/domains/notebook/schemas.py @@ -1,7 +1,23 @@ from datetime import datetime +from typing import Any from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, field_validator + + +APPEARANCE_DEFAULTS = { + "font_family": None, + "theme_id": None, + "font_size": None, + "content_width": None, + "line_height": None, + "paragraph_spacing": None, + "heading_scale": None, + "emphasize_title": None, + "auto_save": None, + "focus_mode_default": None, + "default_right_panel": None, +} class NotebookCreate(BaseModel): @@ -15,6 +31,7 @@ class NotebookUpdate(BaseModel): status: str | None = None cover_emoji: str | None = None cover_gradient: str | None = None + appearance_settings: dict[str, Any] | None = None class NotebookOut(BaseModel): @@ -31,7 +48,17 @@ class NotebookOut(BaseModel): published_at: datetime | None = None cover_emoji: str | None = None cover_gradient: str | None = None + appearance_settings: dict[str, Any] | None = None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} + + @field_validator("appearance_settings", mode="before") + @classmethod + def normalize_appearance_settings(cls, value: Any) -> dict[str, Any] | None: + if value is None: + return None + if not isinstance(value, dict): + return value + return {**APPEARANCE_DEFAULTS, **value} diff --git a/apps/api/app/domains/setup/router.py b/apps/api/app/domains/setup/router.py index b8ab2c8..747882e 100644 --- a/apps/api/app/domains/setup/router.py +++ b/apps/api/app/domains/setup/router.py @@ -5,12 +5,7 @@ from __future__ import annotations -import logging - from fastapi import APIRouter, Response, status -from sqlalchemy import select - -logger = logging.getLogger(__name__) from app.dependencies import DbDep from app.domains.setup.schemas import ( @@ -24,162 +19,41 @@ SetupTestRerankerRequest, SetupTestRerankerResponse, ) -from app.exceptions import ForbiddenError -from app.models import AppConfig, User from app.schemas.response import ApiResponse, not_configured, success +from app.services.config_service import ( + ConfigService, + apply_runtime_settings, + load_settings_from_db, + normalize_runtime_config_key, +) router = APIRouter(tags=["setup"]) -_COOKIE_NAME = "lyranote_session" -_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 - -# Keys that are synced from app_config → in-memory settings at startup -RUNTIME_CONFIG_KEYS = [ - "llm_provider", - "openai_api_key", - "openai_base_url", - "llm_model", - # Utility model (optional small/fast model for utility tasks) - "llm_utility_model", - "llm_utility_api_key", - "llm_utility_base_url", - "embedding_model", - "embedding_api_key", - "embedding_base_url", - "reranker_api_key", - "reranker_base_url", - "reranker_model", - "tavily_api_key", - "perplexity_api_key", - "storage_backend", - "storage_s3_region", - "storage_s3_endpoint_url", - "storage_s3_bucket", - "storage_s3_access_key", - "storage_s3_secret_key", - # Personality - "ai_name", - "user_occupation", - "user_preferences", - "custom_system_prompt", +__all__ = [ + "apply_runtime_settings", + "load_settings_from_db", + "normalize_runtime_config_key", + "router", ] +_COOKIE_NAME = "lyranote_session" +_COOKIE_MAX_AGE = 60 * 60 * 24 * 30 -# ── Helpers ─────────────────────────────────────────────────────────────────── - -async def _get_config(db, key: str) -> str | None: - result = await db.execute(select(AppConfig).where(AppConfig.key == key)) - row = result.scalar_one_or_none() - return row.value if row else None - - -async def _set_config(db, key: str, value: str) -> None: - result = await db.execute(select(AppConfig).where(AppConfig.key == key)) - row = result.scalar_one_or_none() - if row: - row.value = value - else: - db.add(AppConfig(key=key, value=value)) - - -async def load_settings_from_db(db) -> None: - """Called at startup: apply persisted app_config values to in-memory settings.""" - from app.config import settings - result = await db.execute( - select(AppConfig).where(AppConfig.key.in_(RUNTIME_CONFIG_KEYS)) - ) - rows = result.scalars().all() - for row in rows: - if row.value: - try: - setattr(settings, row.key, row.value) - except Exception as e: - logger.warning("Config key %r from DB could not be applied: %s", row.key, e) - - -# ── Routes ──────────────────────────────────────────────────────────────────── @router.get("/setup/status", response_model=ApiResponse[SetupStatusOut]) async def setup_status(db: DbDep): - value = await _get_config(db, "is_configured") - if value == "true": + if await ConfigService(db).get_setup_status(): return success(SetupStatusOut(configured=True)) return not_configured() -@router.post("/setup/init", response_model=ApiResponse[SetupInitResponse], status_code=status.HTTP_201_CREATED) +@router.post( + "/setup/init", + response_model=ApiResponse[SetupInitResponse], + status_code=status.HTTP_201_CREATED, +) async def setup_init(body: SetupInitRequest, response: Response, db: DbDep): - # Guard: only allowed when not yet configured - value = await _get_config(db, "is_configured") - if value == "true": - raise ForbiddenError("系统已初始化") - - from app.auth import hash_password, create_access_token - from app.config import settings - - # Create the sole admin user - user = User( - username=body.username, - password_hash=hash_password(body.password), - name=body.display_name or body.username, - avatar_url=body.avatar_url or None, - email=body.email or None, - ) - db.add(user) - await db.flush() - - # Persist all config to app_config - config_map: dict[str, str] = { - "llm_provider": body.llm_provider, - "openai_api_key": body.openai_api_key, - "openai_base_url": body.openai_base_url, - "llm_model": body.llm_model, - "embedding_model": body.embedding_model, - "embedding_api_key": body.embedding_api_key, - "embedding_base_url": body.embedding_base_url, - "reranker_api_key": body.reranker_api_key, - "reranker_base_url": body.reranker_base_url, - "reranker_model": body.reranker_model, - "tavily_api_key": body.tavily_api_key, - "storage_backend": body.storage_backend, - "storage_region": body.storage_region, - "storage_s3_endpoint_url": body.storage_s3_endpoint_url, - "storage_s3_bucket": body.storage_s3_bucket, - "storage_s3_access_key": body.storage_s3_access_key, - "storage_s3_secret_key": body.storage_s3_secret_key, - # Personality - "ai_name": body.ai_name, - "user_occupation": body.user_occupation, - "user_preferences": body.user_preferences, - "custom_system_prompt": body.custom_system_prompt, - "is_configured": "true", - } - for key, val in config_map.items(): - await _set_config(db, key, val) - - # Apply to in-memory settings immediately - for key, val in config_map.items(): - if key != "is_configured" and val: - try: - setattr(settings, key, val) - except Exception: - pass - - await db.commit() - - token = create_access_token(user.id, expire_days=settings.jwt_expire_days) - - # Trigger async initialization task (create default notebook + welcome note) - try: - from app.workers.tasks import initialize_user_preferences - initialize_user_preferences.delay( - str(user.id), - body.ai_name, - body.user_occupation, - body.user_preferences, - ) - except Exception: - pass # Non-critical: task broker may not be available immediately + token = await ConfigService(db).setup_init(body) response.set_cookie( key=_COOKIE_NAME, @@ -194,94 +68,35 @@ async def setup_init(body: SetupInitRequest, response: Response, db: DbDep): return success(SetupInitResponse(access_token=token)) -# ── Public LLM connectivity test (no auth, for setup wizard) ────────────── - - @router.post("/setup/test-llm", response_model=ApiResponse[SetupTestLlmResponse]) async def setup_test_llm(body: SetupTestLlmRequest): """Quick connectivity check — sends a tiny request to verify key + endpoint.""" - if not body.api_key: - return success(SetupTestLlmResponse(ok=False, message="未提供 API Key")) - - try: - if body.llm_provider == "litellm": - import litellm - call_kw: dict = dict( - model=body.model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - api_key=body.api_key, - drop_params=True, - ) - if body.model.startswith("gemini/"): - call_kw["custom_llm_provider"] = "gemini" - if body.base_url: - call_kw["api_base"] = body.base_url - resp = await litellm.acompletion(**call_kw) - reply = (resp.choices[0].message.content or "").strip() - else: - from openai import AsyncOpenAI - client = AsyncOpenAI( - api_key=body.api_key, - base_url=body.base_url or None, - timeout=15.0, - ) - resp = await client.chat.completions.create( - model=body.model, - messages=[{"role": "user", "content": "Hi"}], - max_tokens=100, - ) - reply = (resp.choices[0].message.content or "").strip() - return success(SetupTestLlmResponse(ok=True, message=reply or "OK")) - except Exception as exc: - return success(SetupTestLlmResponse(ok=False, message=str(exc)[:200])) + result = await ConfigService.test_llm_connection( + api_key=body.api_key, + base_url=body.base_url, + model=body.model, + llm_provider=body.llm_provider, + ) + return success(SetupTestLlmResponse(**result)) @router.post("/setup/test-embedding", response_model=ApiResponse[SetupTestEmbeddingResponse]) async def setup_test_embedding(body: SetupTestEmbeddingRequest): """Test Embedding API connectivity with provided (or default) credentials.""" - from openai import AsyncOpenAI - - api_key = body.api_key.strip() - if not api_key: - return success(SetupTestEmbeddingResponse(ok=False, dimensions=0, message="未提供 API Key")) - - client = AsyncOpenAI( - api_key=api_key, - base_url=body.base_url.strip() or None, - timeout=15.0, + result = await ConfigService.test_embedding_connection( + api_key=body.api_key, + base_url=body.base_url, + model=body.model, ) - try: - resp = await client.embeddings.create(model=body.model, input=["test"]) - dims = len(resp.data[0].embedding) - return success(SetupTestEmbeddingResponse(ok=True, dimensions=dims, message=f"维度 {dims}")) - except Exception as exc: - return success(SetupTestEmbeddingResponse(ok=False, dimensions=0, message=str(exc)[:200])) + return success(SetupTestEmbeddingResponse(**result)) @router.post("/setup/test-reranker", response_model=ApiResponse[SetupTestRerankerResponse]) async def setup_test_reranker(body: SetupTestRerankerRequest): """Test Reranker API connectivity with provided credentials.""" - import httpx - - api_key = body.api_key.strip() - if not api_key: - return success(SetupTestRerankerResponse(ok=False, message="未提供 API Key")) - - base_url = body.base_url.rstrip("/") if body.base_url else "https://api.siliconflow.cn/v1" - try: - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post( - f"{base_url}/rerank", - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - json={"model": body.model, "query": "test", "documents": ["hello world"], "top_n": 1, "return_documents": False}, - ) - resp.raise_for_status() - data = resp.json() - results = data.get("results", []) - if results: - score = round(results[0].get("relevance_score", 0), 4) - return success(SetupTestRerankerResponse(ok=True, message=f"Score {score}")) - return success(SetupTestRerankerResponse(ok=True, message="OK")) - except Exception as exc: - return success(SetupTestRerankerResponse(ok=False, message=str(exc)[:200])) + result = await ConfigService.test_reranker_connection( + api_key=body.api_key, + base_url=body.base_url, + model=body.model, + ) + return success(SetupTestRerankerResponse(**result)) diff --git a/apps/api/app/domains/setup/schemas.py b/apps/api/app/domains/setup/schemas.py index 581a9e0..ed41156 100644 --- a/apps/api/app/domains/setup/schemas.py +++ b/apps/api/app/domains/setup/schemas.py @@ -46,7 +46,6 @@ class SetupInitRequest(BaseModel): ai_name: str = "Lyra" user_occupation: str = "" user_preferences: str = "" - custom_system_prompt: str = "" @field_validator("username") @classmethod diff --git a/apps/api/app/domains/source/router.py b/apps/api/app/domains/source/router.py index 0152542..c499f83 100644 --- a/apps/api/app/domains/source/router.py +++ b/apps/api/app/domains/source/router.py @@ -6,7 +6,15 @@ from app.dependencies import CurrentUser, DbDep from app.schemas.response import ApiResponse, success from app.services.source_service import DownloadRedirect, SourceService -from .schemas import ChunkOut, RechunkRequest, SourceImportUrl, SourceOut, SourcePage, SourceUpdate +from .schemas import ( + ChunkOut, + RechunkRequest, + SourceImportPath, + SourceImportUrl, + SourceOut, + SourcePage, + SourceUpdate, +) router = APIRouter(tags=["sources"]) @@ -148,3 +156,18 @@ async def import_global_source_url( svc = SourceService(db, current_user.id) source = await svc.import_global_source_url(body.url, body.title) return success(source) + + +@router.post( + "/sources/global/import-path", + response_model=ApiResponse[SourceOut], + status_code=status.HTTP_201_CREATED, +) +async def import_global_source_path( + body: SourceImportPath, + db: DbDep, + current_user: CurrentUser, +): + svc = SourceService(db, current_user.id) + source = await svc.import_global_source_path(body.path, sha256=body.sha256) + return success(source) diff --git a/apps/api/app/domains/source/schemas.py b/apps/api/app/domains/source/schemas.py index 5fb4315..e32c02d 100644 --- a/apps/api/app/domains/source/schemas.py +++ b/apps/api/app/domains/source/schemas.py @@ -9,6 +9,11 @@ class SourceImportUrl(BaseModel): title: str | None = None +class SourceImportPath(BaseModel): + path: str + sha256: str | None = None + + class SourceOut(BaseModel): id: UUID notebook_id: UUID diff --git a/apps/api/app/domains/upload/router.py b/apps/api/app/domains/upload/router.py index f89b852..696918d 100644 --- a/apps/api/app/domains/upload/router.py +++ b/apps/api/app/domains/upload/router.py @@ -6,50 +6,22 @@ back when processing a message with `attachment_ids`. """ -import mimetypes -import uuid +from fastapi import APIRouter, UploadFile -from fastapi import APIRouter, HTTPException, UploadFile -from fastapi.responses import Response - -from app.dependencies import CurrentUser, DbDep -from app.providers.storage import storage +from app.dependencies import CurrentUser from app.schemas.response import success +from app.services.upload_service import UploadService router = APIRouter(tags=["uploads"]) -MAX_UPLOAD_SIZE = 20 * 1024 * 1024 # 20 MB - -_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") - @router.post("/uploads/temp") async def upload_temp_file( file: UploadFile, current_user: CurrentUser, - db: DbDep, ): - content = await file.read() - if len(content) > MAX_UPLOAD_SIZE: - raise HTTPException(status_code=413, detail="文件大小不能超过 20 MB") - - ext = "" - if file.filename: - ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" - - file_id = str(uuid.uuid4()) - content_type = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream" - storage_key = f"temp/{current_user.id}/{file_id}{ext}" - - await storage().upload(storage_key, content, content_type) - - return success({ - "id": file_id, - "storage_key": storage_key, - "filename": file.filename or f"{file_id}{ext}", - "content_type": content_type, - "size": len(content), - }) + payload = await UploadService().upload_temp_file(file, str(current_user.id)) + return success(payload) @router.get("/uploads/temp/{file_id}") @@ -58,15 +30,4 @@ async def get_temp_file( current_user: CurrentUser, ): """Serve a temp-uploaded file back to the uploader (for image preview).""" - store = storage() - for ext in ("", ".pdf", ".txt", ".md", ".doc", ".docx", *_IMAGE_EXTS): - key = f"temp/{current_user.id}/{file_id}{ext}" - try: - if not await store.exists(key): - continue - data = await store.download(key) - ct = mimetypes.guess_type(f"f{ext}")[0] or "application/octet-stream" - return Response(content=data, media_type=ct) - except FileNotFoundError: - continue - raise HTTPException(status_code=404, detail="File not found") + return await UploadService().get_temp_file(file_id, str(current_user.id)) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index dcb86c2..33d8a26 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1,7 +1,9 @@ from __future__ import annotations import logging +import json from contextlib import asynccontextmanager +from datetime import datetime, timezone from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware @@ -19,7 +21,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): from app.logging_config import setup_logging - setup_logging(debug=settings.debug) + setup_logging(debug=settings.debug, logs_dir=settings.logs_dir) if not settings.jwt_secret: logger.warning( @@ -27,52 +29,92 @@ async def lifespan(app: FastAPI): "will be invalidated on every process restart. Set JWT_SECRET in .env for production." ) - from app.database import engine, AsyncSessionLocal + from app.database import Base, engine, AsyncSessionLocal from sqlalchemy import text - async with engine.begin() as conn: - await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + if settings.is_desktop_runtime and settings.database_url.startswith("sqlite"): + from app import models as _models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + elif settings.database_url.startswith("postgresql"): + async with engine.begin() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) # Load persisted config from app_config table into in-memory settings - from app.domains.setup.router import load_settings_from_db + from app.services.config_service import load_settings_from_db async with AsyncSessionLocal() as db: await load_settings_from_db(db) # Bootstrap built-in skills (and optionally load workspace/user skills) - from app.skills.registry import bootstrap_builtin_skills + from app.skills.registry import bootstrap_builtin_skills, skill_registry bootstrap_builtin_skills() - # On startup, immediately expire any sources left stuck in 'processing' / 'pending' - # from a previous process crash or restart (belt-and-suspenders alongside the beat task). + # Sync in-memory skill registry → skill_installs table (upsert metadata, preserve is_enabled/config) try: - from datetime import datetime, timedelta, timezone - from sqlalchemy import select - from app.models import Source as _Source + from sqlalchemy import select as _select + from app.models import SkillInstall + import uuid as _uuid async with AsyncSessionLocal() as _db: - _cutoff = datetime.now(timezone.utc) - timedelta(minutes=5) - _result = await _db.execute( - select(_Source).where( - _Source.status.in_(["processing", "pending"]), - _Source.updated_at < _cutoff, - ) - ) - _stuck = _result.scalars().all() - if _stuck: - for _src in _stuck: - _src.status = "failed" - _src.metadata_ = {**(_src.metadata_ or {}), "error": "indexing_timeout"} - await _db.commit() - logger.warning("startup: expired %d stuck source(s)", len(_stuck)) + for _skill in skill_registry.all_skills(): + _m = _skill.meta + _existing = ( + await _db.execute( + _select(SkillInstall).where(SkillInstall.name == _m.name) + ) + ).scalar_one_or_none() + if _existing is None: + _db.add(SkillInstall( + id=_uuid.uuid4(), + name=_m.name, + display_name=_m.display_name, + description=_m.description, + category=_m.category, + version=_m.version, + is_builtin=True, + is_enabled=True, + always=_m.always, + requires_env=_m.requires_env or None, + config_schema=_m.config_schema, + )) + else: + # Update metadata only; preserve is_enabled / config set by admin/user + _existing.display_name = _m.display_name + _existing.description = _m.description + _existing.category = _m.category + _existing.version = _m.version + _existing.always = _m.always + _existing.requires_env = _m.requires_env or None + _existing.config_schema = _m.config_schema + await _db.commit() + logger.info("skill_installs synced: %d skills", len(skill_registry.all_skills())) except Exception: - logger.exception("startup: failed to expire stuck sources (non-fatal)") + logger.exception("startup: failed to sync skill_installs (non-fatal)") + + if not settings.is_desktop_runtime: + # On startup, immediately expire any sources left stuck in 'processing' / 'pending' + # from a previous process crash or restart (belt-and-suspenders alongside the beat task). + try: + from app.workers.tasks.ingestion import _expire_stuck_sources_impl + + async with AsyncSessionLocal() as _db: + _expired = await _expire_stuck_sources_impl(_db) + if _expired: + logger.warning("startup: expired %d stuck source(s)", _expired) + except Exception: + logger.exception("startup: failed to expire stuck sources (non-fatal)") # Initialize file-based memory storage (create dirs + default MEMORY.md) from app.agents.memory.file_storage import init_memory_storage init_memory_storage() - # Start Lyra Soul — persistent background thinking loop - from app.agents.soul.soul import soul - await soul.start() + soul = None + if not settings.is_desktop_runtime: + # Start Lyra Soul — persistent background thinking loop + from app.agents.soul.soul import soul as agent_soul + + soul = agent_soul + await soul.start() heartbeat_task = None heartbeat_stop = None @@ -84,6 +126,24 @@ async def lifespan(app: FastAPI): app.state.api_heartbeat_stop = heartbeat_stop app.state.api_heartbeat_task = heartbeat_task + if settings.is_desktop_runtime and settings.desktop_stdout_events: + print( + json.dumps( + { + "type": "runtime.ready", + "payload": { + "profile": settings.runtime_profile, + "database_url": settings.database_url, + "memory_mode": settings.memory_mode, + "version": app.version, + }, + "occurred_at": datetime.now(timezone.utc).isoformat(), + }, + ensure_ascii=False, + ), + flush=True, + ) + yield if heartbeat_stop is not None: @@ -91,8 +151,9 @@ async def lifespan(app: FastAPI): if heartbeat_task is not None: await heartbeat_task - # Shutdown Lyra Soul - await soul.stop() + if soul is not None: + # Shutdown Lyra Soul + await soul.stop() app = FastAPI( @@ -200,6 +261,7 @@ async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSON from app.domains.monitoring.router import router as monitoring_router from app.domains.portrait.router import router as portrait_router from app.domains.public_home.router import router as public_home_router +from app.domains.desktop.router import router as desktop_router app.include_router(auth_router, prefix="/api/v1") app.include_router(setup_router, prefix="/api/v1") @@ -224,6 +286,7 @@ async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSON app.include_router(monitoring_router, prefix="/api/v1") app.include_router(portrait_router, prefix="/api/v1") app.include_router(public_home_router, prefix="/api/v1") +app.include_router(desktop_router, prefix="/api/v1") @app.get("/health") @@ -244,18 +307,20 @@ async def health(): logger.warning("Health check DB failed: %s", e) checks["db"] = "error" - # Redis ping - try: - import redis.asyncio as aioredis - r = aioredis.from_url(settings.redis_url, socket_connect_timeout=2) - await r.ping() - await r.aclose() - checks["redis"] = "ok" - except Exception as e: - logger.warning("Health check Redis failed: %s", e) - checks["redis"] = "error" - - all_ok = all(v == "ok" for v in checks.values()) + if settings.is_desktop_runtime: + checks["redis"] = "skipped" + else: + try: + import redis.asyncio as aioredis + r = aioredis.from_url(settings.redis_url, socket_connect_timeout=2) + await r.ping() + await r.aclose() + checks["redis"] = "ok" + except Exception as e: + logger.warning("Health check Redis failed: %s", e) + checks["redis"] = "error" + + all_ok = all(v in {"ok", "skipped"} for v in checks.values()) payload = {"status": "ok" if all_ok else "degraded", "version": "0.1.0", **checks} if not all_ok: diff --git a/apps/api/app/mcp/client.py b/apps/api/app/mcp/client.py index 964bbc2..c7fbb30 100644 --- a/apps/api/app/mcp/client.py +++ b/apps/api/app/mcp/client.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import logging import os from contextlib import asynccontextmanager @@ -55,6 +56,8 @@ async def get_tools(self, config: "MCPServerConfig") -> list[dict]: try: return await self._run(config, "list_tools") except BaseException as exc: + if isinstance(exc, asyncio.CancelledError): + raise msg = _exc_msg(exc) logger.warning("MCP get_tools failed for server '%s': %s", config.name, msg) return [] @@ -74,6 +77,8 @@ async def call_tool( try: return await self._run(config, "call_tool", tool_name=tool_name, arguments=arguments) except BaseException as exc: + if isinstance(exc, asyncio.CancelledError): + raise last_exc = exc if attempt == 0: logger.warning( diff --git a/apps/api/app/models/notebook.py b/apps/api/app/models/notebook.py index e2b7a2f..9f51354 100644 --- a/apps/api/app/models/notebook.py +++ b/apps/api/app/models/notebook.py @@ -26,6 +26,7 @@ class Notebook(Base): published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) cover_emoji: Mapped[str | None] = mapped_column(String(10), nullable=True) cover_gradient: Mapped[str | None] = mapped_column(String(50), nullable=True) + appearance_settings: Mapped[dict | None] = mapped_column(json_type) created_at: Mapped[datetime] = now_col() updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() diff --git a/apps/api/app/models/observability.py b/apps/api/app/models/observability.py index d084eb3..88bd1a5 100644 --- a/apps/api/app/models/observability.py +++ b/apps/api/app/models/observability.py @@ -3,7 +3,16 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) from sqlalchemy import JSON as SAJSON from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -16,6 +25,11 @@ class ObservabilityRun(Base): __tablename__ = "observability_runs" + __table_args__ = ( + Index("ix_observability_runs_started_at", "started_at"), + Index("ix_observability_runs_run_type_started_at", "run_type", "started_at"), + Index("ix_observability_runs_status_started_at", "status", "started_at"), + ) id: Mapped[uuid.UUID] = uuid_pk() trace_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) @@ -24,20 +38,24 @@ class ObservabilityRun(Base): status: Mapped[str] = mapped_column(String(20), index=True, nullable=False, default="running") user_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), + index=True, nullable=True, ) conversation_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("conversations.id", ondelete="SET NULL"), + index=True, nullable=True, ) generation_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("message_generations.id", ondelete="SET NULL"), + index=True, nullable=True, ) - task_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True) - task_run_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True) + task_id: Mapped[uuid.UUID | None] = mapped_column(index=True, nullable=True) + task_run_id: Mapped[uuid.UUID | None] = mapped_column(index=True, nullable=True) notebook_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("notebooks.id", ondelete="SET NULL"), + index=True, nullable=True, ) duration_ms: Mapped[int | None] = mapped_column(Integer) @@ -65,6 +83,10 @@ class ObservabilityRun(Base): class ObservabilitySpan(Base): __tablename__ = "observability_spans" + __table_args__ = ( + Index("ix_observability_spans_started_at", "started_at"), + Index("ix_observability_spans_trace_id_started_at", "trace_id", "started_at"), + ) id: Mapped[uuid.UUID] = uuid_pk() run_id: Mapped[uuid.UUID] = mapped_column( @@ -72,8 +94,15 @@ class ObservabilitySpan(Base): nullable=False, index=True, ) + parent_span_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("observability_spans.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) trace_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) span_name: Mapped[str] = mapped_column(String(120), nullable=False) + component: Mapped[str | None] = mapped_column(String(20)) + span_kind: Mapped[str | None] = mapped_column(String(20)) status: Mapped[str] = mapped_column(String(20), nullable=False, default="running") duration_ms: Mapped[int | None] = mapped_column(Integer) error_message: Mapped[str | None] = mapped_column(Text) diff --git a/apps/api/app/services/config_service.py b/apps/api/app/services/config_service.py new file mode 100644 index 0000000..b91c619 --- /dev/null +++ b/apps/api/app/services/config_service.py @@ -0,0 +1,727 @@ +""" +Config service — runtime config persistence, reload, and connectivity checks. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.exceptions import ForbiddenError +from app.models import AppConfig, User + +logger = logging.getLogger(__name__) + +MASKED_VALUE = "••••••••" + +EDITABLE_KEYS = { + "llm_provider", + "openai_api_key", + "openai_base_url", + "llm_model", + "llm_utility_model", + "llm_utility_api_key", + "llm_utility_base_url", + "embedding_model", + "embedding_api_key", + "embedding_base_url", + "reranker_api_key", + "reranker_model", + "reranker_base_url", + "tavily_api_key", + "perplexity_api_key", + "image_gen_api_key", + "image_gen_base_url", + "image_gen_model", + "storage_backend", + "storage_region", + "storage_s3_endpoint_url", + "storage_s3_public_url", + "storage_s3_bucket", + "storage_s3_access_key", + "storage_s3_secret_key", + "ai_name", + "user_occupation", + "user_preferences", + "notify_email", + "smtp_host", + "smtp_port", + "smtp_username", + "smtp_password", + "smtp_from", + "notebook_appearance_defaults", +} + +SENSITIVE_KEYS = { + "openai_api_key", + "llm_utility_api_key", + "embedding_api_key", + "reranker_api_key", + "storage_s3_access_key", + "storage_s3_secret_key", + "tavily_api_key", + "perplexity_api_key", + "image_gen_api_key", + "smtp_password", +} + +LEGACY_RUNTIME_CONFIG_KEY_ALIASES = { + "storage_region": "storage_s3_region", +} + +RUNTIME_CONFIG_KEYS = { + "llm_provider", + "openai_api_key", + "openai_base_url", + "llm_model", + "llm_utility_model", + "llm_utility_api_key", + "llm_utility_base_url", + "embedding_model", + "embedding_api_key", + "embedding_base_url", + "reranker_api_key", + "reranker_base_url", + "reranker_model", + "tavily_api_key", + "perplexity_api_key", + "image_gen_api_key", + "image_gen_base_url", + "image_gen_model", + "storage_backend", + "storage_s3_region", + "storage_s3_endpoint_url", + "storage_s3_public_url", + "storage_s3_bucket", + "storage_s3_access_key", + "storage_s3_secret_key", + "ai_name", + "user_occupation", + "user_preferences", +} + +RUNTIME_CONFIG_QUERY_KEYS = [ + *sorted(RUNTIME_CONFIG_KEYS), + *sorted(LEGACY_RUNTIME_CONFIG_KEY_ALIASES.keys()), +] + +PROVIDER_RUNTIME_KEYS = { + "llm_provider", + "openai_api_key", + "openai_base_url", + "llm_model", + "llm_utility_model", + "llm_utility_api_key", + "llm_utility_base_url", +} + +EMBEDDING_RUNTIME_KEYS = { + "openai_api_key", + "openai_base_url", + "embedding_api_key", + "embedding_base_url", + "embedding_model", +} + +RERANKER_RUNTIME_KEYS = { + "reranker_api_key", + "reranker_base_url", + "reranker_model", +} + +STORAGE_RUNTIME_KEYS = { + "storage_backend", + "storage_s3_region", + "storage_s3_endpoint_url", + "storage_s3_public_url", + "storage_s3_bucket", + "storage_s3_access_key", + "storage_s3_secret_key", +} + + +def normalize_runtime_config_key(key: str) -> str: + return LEGACY_RUNTIME_CONFIG_KEY_ALIASES.get(key, key) + + +def apply_runtime_settings(config_map: Mapping[str, str | None]) -> set[str]: + """Apply persisted runtime config to in-memory settings and reset stale clients.""" + touched_keys: set[str] = set() + for key, value in config_map.items(): + normalized_key = normalize_runtime_config_key(key) + if normalized_key not in RUNTIME_CONFIG_KEYS or value is None: + continue + try: + setattr(settings, normalized_key, value) + touched_keys.add(normalized_key) + except Exception as exc: + logger.warning( + "Config key %r from DB could not be applied: %s", + normalized_key, + exc, + ) + + if touched_keys & PROVIDER_RUNTIME_KEYS: + try: + from app.providers.provider_factory import reset_provider + + reset_provider() + except Exception: + logger.exception("Failed to reset LLM provider after runtime config sync") + + if touched_keys & EMBEDDING_RUNTIME_KEYS: + try: + from app.providers import embedding + + embedding._client = None # type: ignore[attr-defined] + except Exception: + logger.exception("Failed to reset embedding client after runtime config sync") + + if touched_keys & RERANKER_RUNTIME_KEYS: + try: + from app.providers import reranker + + reranker._client = None # type: ignore[attr-defined] + except Exception: + logger.exception("Failed to reset reranker client after runtime config sync") + + if touched_keys & STORAGE_RUNTIME_KEYS: + try: + from app.providers.storage import reset_storage_instance + + reset_storage_instance() + except Exception: + logger.exception("Failed to reset storage provider after runtime config sync") + + return touched_keys + + +async def load_settings_from_db(db: AsyncSession) -> None: + """Apply persisted app_config values to in-memory settings.""" + result = await db.execute( + select(AppConfig).where(AppConfig.key.in_(RUNTIME_CONFIG_QUERY_KEYS)) + ) + rows = result.scalars().all() + config_map: dict[str, str | None] = {} + for row in rows: + normalized_key = normalize_runtime_config_key(row.key) + is_legacy_key = normalized_key != row.key + if is_legacy_key and normalized_key in config_map: + continue + config_map[normalized_key] = row.value + apply_runtime_settings(config_map) + + +async def _run_chat_connection_test( + *, + provider: str, + api_key: str, + base_url: str | None, + model: str, + prompt: str = "Hi", + allow_model_provider_hint: bool = False, +) -> str: + use_litellm = provider == "litellm" or (allow_model_provider_hint and "/" in model) + if use_litellm: + import litellm + + call_kw: dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 100, + "api_key": api_key, + "drop_params": True, + } + if model.startswith("gemini/"): + call_kw["custom_llm_provider"] = "gemini" + if base_url: + call_kw["api_base"] = base_url + resp = await litellm.acompletion(**call_kw) + return (resp.choices[0].message.content or "").strip() + + from openai import AsyncOpenAI + + client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=15.0) + resp = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + max_tokens=100, + ) + return (resp.choices[0].message.content or "").strip() + + +async def _run_embedding_connection_test( + *, + api_key: str, + base_url: str | None, + model: str, +) -> int: + from openai import AsyncOpenAI + + client = AsyncOpenAI(api_key=api_key, base_url=base_url, timeout=15.0) + resp = await client.embeddings.create(model=model, input=["test"]) + return len(resp.data[0].embedding) + + +async def _run_reranker_connection_test( + *, + api_key: str, + base_url: str, + model: str, +) -> str: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + f"{base_url.rstrip('/')}/rerank", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "query": "test", + "documents": ["hello world"], + "top_n": 1, + "return_documents": False, + }, + ) + resp.raise_for_status() + data = resp.json() + results = data.get("results", []) + if results: + score = round(results[0].get("relevance_score", 0), 4) + return f"Score {score}" + return "OK" + + +class ConfigService: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_config_value(self, key: str) -> str | None: + result = await self.db.execute(select(AppConfig).where(AppConfig.key == key)) + row = result.scalar_one_or_none() + return row.value if row else None + + async def set_config_value(self, key: str, value: str) -> None: + result = await self.db.execute(select(AppConfig).where(AppConfig.key == key)) + row = result.scalar_one_or_none() + if row: + row.value = value + else: + self.db.add(AppConfig(key=key, value=value)) + + async def get_runtime_config(self) -> dict[str, str | None]: + queryable_keys = (EDITABLE_KEYS - {"storage_region"}) | {"storage_s3_region"} + result = await self.db.execute( + select(AppConfig).where(AppConfig.key.in_(queryable_keys)) + ) + rows = result.scalars().all() + stored_values: dict[str, str | None] = {} + for row in rows: + if row.key in SENSITIVE_KEYS and row.value: + stored_values[row.key] = MASKED_VALUE + else: + stored_values[row.key] = row.value + + config: dict[str, str | None] = {key: None for key in EDITABLE_KEYS} + for key in EDITABLE_KEYS: + normalized_key = normalize_runtime_config_key(key) + config[key] = stored_values.get(normalized_key) + if config[key] is None and normalized_key != key: + config[key] = stored_values.get(key) + return config + + async def update_runtime_config(self, patch: Mapping[str, Any]) -> None: + query_keys = { + candidate + for key in patch + for candidate in {key, normalize_runtime_config_key(key)} + } + result = await self.db.execute( + select(AppConfig).where(AppConfig.key.in_(query_keys)) + ) + existing_rows = {row.key: row for row in result.scalars().all()} + + runtime_updates: dict[str, str] = {} + for key, value in patch.items(): + str_value = str(value) if value is not None else "" + if str_value == MASKED_VALUE: + continue + + normalized_key = normalize_runtime_config_key(key) + row = existing_rows.get(normalized_key) + if row: + row.value = str_value + else: + row = AppConfig(key=normalized_key, value=str_value) + self.db.add(row) + existing_rows[normalized_key] = row + + if normalized_key != key: + legacy_row = existing_rows.get(key) + if legacy_row is not None: + await self.db.delete(legacy_row) + existing_rows.pop(key, None) + + runtime_updates[normalized_key] = str_value + + await self.db.commit() + apply_runtime_settings(runtime_updates) + + async def get_setup_status(self) -> bool: + value = await self.get_config_value("is_configured") + return value == "true" + + async def setup_init(self, body: Any) -> str: + if await self.get_setup_status(): + raise ForbiddenError("系统已初始化") + + from app.auth import create_access_token, hash_password + + user = User( + username=body.username, + password_hash=hash_password(body.password), + name=body.display_name or body.username, + avatar_url=body.avatar_url or None, + email=body.email or None, + ) + self.db.add(user) + await self.db.flush() + + config_map: dict[str, str] = { + "llm_provider": body.llm_provider, + "openai_api_key": body.openai_api_key, + "openai_base_url": body.openai_base_url, + "llm_model": body.llm_model, + "embedding_model": body.embedding_model, + "embedding_api_key": body.embedding_api_key, + "embedding_base_url": body.embedding_base_url, + "reranker_api_key": body.reranker_api_key, + "reranker_base_url": body.reranker_base_url, + "reranker_model": body.reranker_model, + "tavily_api_key": body.tavily_api_key, + "storage_backend": body.storage_backend, + "storage_s3_region": body.storage_region, + "storage_s3_endpoint_url": body.storage_s3_endpoint_url, + "storage_s3_bucket": body.storage_s3_bucket, + "storage_s3_access_key": body.storage_s3_access_key, + "storage_s3_secret_key": body.storage_s3_secret_key, + "ai_name": body.ai_name, + "user_occupation": body.user_occupation, + "user_preferences": body.user_preferences, + "is_configured": "true", + } + for key, value in config_map.items(): + await self.set_config_value(key, value) + + from app.services.memory_service import MemoryService + + await MemoryService(self.db, user.id).bootstrap_setup_memories( + user_occupation=body.user_occupation, + user_preferences=body.user_preferences, + ) + + await self.db.commit() + apply_runtime_settings( + {key: value for key, value in config_map.items() if key != "is_configured"} + ) + + token = create_access_token(user.id, expire_days=settings.jwt_expire_days) + + try: + from app.workers.tasks import initialize_user_preferences + + initialize_user_preferences.delay( + str(user.id), + body.ai_name, + body.user_occupation, + body.user_preferences, + ) + except Exception as exc: + logger.debug("Setup init background task skipped: %s", exc) + + return token + + async def test_email(self) -> dict[str, Any]: + from app.providers.email import send_email + + result = await self.db.execute( + select(AppConfig).where( + AppConfig.key.in_( + { + "notify_email", + "smtp_host", + "smtp_port", + "smtp_username", + "smtp_password", + "smtp_from", + } + ) + ) + ) + cfg = {row.key: row.value for row in result.scalars().all()} + + to = cfg.get("notify_email", "") + if not to: + return {"ok": False, "message": "未设置通知邮箱地址"} + + if not cfg.get("smtp_host") or not cfg.get("smtp_username"): + return {"ok": False, "message": "SMTP 未配置完整"} + + html = """
+

LyraNote 测试邮件

+

如果你收到了这封邮件,说明 SMTP 配置正确,邮件功能已可正常使用。

+

— LyraNote

+
""" + + result = await send_email( + to=to, + subject="LyraNote 测试邮件", + html_body=html, + text_body="如果你收到了这封邮件,说明 SMTP 配置正确。", + smtp_config=cfg, + ) + + if result.ok: + return {"ok": True, "message": f"测试邮件已发送至 {to}"} + if result.error: + return {"ok": False, "message": f"发送失败:{result.error}"} + return {"ok": False, "message": "发送失败,请检查 SMTP 配置"} + + async def test_saved_llm_connection(self) -> dict[str, Any]: + rows = await self._get_config_rows( + {"llm_provider", "openai_api_key", "openai_base_url", "llm_model"} + ) + provider = rows.get("llm_provider") or settings.llm_provider or "openai" + api_key = rows.get("openai_api_key") or settings.openai_api_key + base_url = rows.get("openai_base_url") or settings.openai_base_url or None + model = rows.get("llm_model") or settings.llm_model + + if not api_key: + return {"ok": False, "model": model, "message": "未设置 API Key"} + + try: + reply = await _run_chat_connection_test( + provider=provider, + api_key=api_key, + base_url=base_url, + model=model, + ) + return {"ok": True, "model": model, "message": reply or "OK"} + except Exception as exc: + return {"ok": False, "model": model, "message": str(exc)[:200]} + + async def test_saved_utility_llm_connection(self) -> dict[str, Any]: + rows = await self._get_config_rows( + { + "llm_provider", + "openai_api_key", + "openai_base_url", + "llm_utility_model", + "llm_utility_api_key", + "llm_utility_base_url", + } + ) + utility_model = rows.get("llm_utility_model") or settings.llm_utility_model + if not utility_model: + return {"ok": False, "model": "", "message": "未配置小模型"} + + api_key = ( + rows.get("llm_utility_api_key") + or settings.llm_utility_api_key + or rows.get("openai_api_key") + or settings.openai_api_key + ) + base_url = ( + rows.get("llm_utility_base_url") + or settings.llm_utility_base_url + or rows.get("openai_base_url") + or settings.openai_base_url + or None + ) + provider = rows.get("llm_provider") or settings.llm_provider or "openai" + + if not api_key: + return {"ok": False, "model": utility_model, "message": "未设置 API Key"} + + try: + reply = await _run_chat_connection_test( + provider=provider, + api_key=api_key, + base_url=base_url, + model=utility_model, + allow_model_provider_hint=True, + ) + return {"ok": True, "model": utility_model, "message": reply or "OK"} + except Exception as exc: + return {"ok": False, "model": utility_model, "message": str(exc)[:200]} + + async def test_saved_embedding_connection(self) -> dict[str, Any]: + rows = await self._get_config_rows( + { + "openai_api_key", + "openai_base_url", + "embedding_model", + "embedding_api_key", + "embedding_base_url", + } + ) + api_key = ( + rows.get("embedding_api_key") + or rows.get("openai_api_key") + or settings.embedding_api_key + or settings.openai_api_key + ) + base_url = ( + rows.get("embedding_base_url") + or rows.get("openai_base_url") + or settings.embedding_base_url + or settings.openai_base_url + or None + ) + model = rows.get("embedding_model") or settings.embedding_model + + if not api_key: + return { + "ok": False, + "model": model, + "dimensions": 0, + "message": "未设置 API Key", + } + + try: + dimensions = await _run_embedding_connection_test( + api_key=api_key, + base_url=base_url, + model=model, + ) + return { + "ok": True, + "model": model, + "dimensions": dimensions, + "message": f"维度 {dimensions}", + } + except Exception as exc: + return { + "ok": False, + "model": model, + "dimensions": 0, + "message": str(exc)[:200], + } + + async def test_saved_reranker_connection(self) -> dict[str, Any]: + rows = await self._get_config_rows( + { + "openai_api_key", + "openai_base_url", + "reranker_api_key", + "reranker_base_url", + "reranker_model", + } + ) + api_key = ( + rows.get("reranker_api_key") + or rows.get("openai_api_key") + or settings.reranker_api_key + or settings.openai_api_key + ) + base_url = ( + rows.get("reranker_base_url") + or rows.get("openai_base_url") + or settings.reranker_base_url + or settings.openai_base_url + or "" + ).rstrip("/") + model = rows.get("reranker_model") or settings.reranker_model + + if not api_key: + return {"ok": False, "model": model, "message": "未设置 API Key"} + if not base_url: + return {"ok": False, "model": model, "message": "未设置 Base URL"} + + try: + message = await _run_reranker_connection_test( + api_key=api_key, + base_url=base_url, + model=model, + ) + return {"ok": True, "model": model, "message": message} + except Exception as exc: + return {"ok": False, "model": model, "message": str(exc)[:200]} + + async def _get_config_rows(self, keys: set[str]) -> dict[str, str | None]: + result = await self.db.execute(select(AppConfig).where(AppConfig.key.in_(keys))) + return {row.key: row.value for row in result.scalars().all()} + + @staticmethod + async def test_llm_connection( + *, + api_key: str, + base_url: str, + model: str, + llm_provider: str, + ) -> dict[str, Any]: + if not api_key: + return {"ok": False, "message": "未提供 API Key"} + try: + reply = await _run_chat_connection_test( + provider=llm_provider, + api_key=api_key, + base_url=base_url or None, + model=model, + ) + return {"ok": True, "message": reply or "OK"} + except Exception as exc: + return {"ok": False, "message": str(exc)[:200]} + + @staticmethod + async def test_embedding_connection( + *, + api_key: str, + base_url: str, + model: str, + ) -> dict[str, Any]: + api_key = api_key.strip() + if not api_key: + return {"ok": False, "dimensions": 0, "message": "未提供 API Key"} + try: + dimensions = await _run_embedding_connection_test( + api_key=api_key, + base_url=base_url.strip() or None, + model=model, + ) + return { + "ok": True, + "dimensions": dimensions, + "message": f"维度 {dimensions}", + } + except Exception as exc: + return {"ok": False, "dimensions": 0, "message": str(exc)[:200]} + + @staticmethod + async def test_reranker_connection( + *, + api_key: str, + base_url: str, + model: str, + ) -> dict[str, Any]: + api_key = api_key.strip() + if not api_key: + return {"ok": False, "message": "未提供 API Key"} + + reranker_base_url = base_url.rstrip("/") if base_url else "https://api.siliconflow.cn/v1" + try: + message = await _run_reranker_connection_test( + api_key=api_key, + base_url=reranker_base_url, + model=model, + ) + return {"ok": True, "message": message} + except Exception as exc: + return {"ok": False, "message": str(exc)[:200]} diff --git a/apps/api/app/services/conversation_service.py b/apps/api/app/services/conversation_service.py index 3124ec0..fe5a7a3 100644 --- a/apps/api/app/services/conversation_service.py +++ b/apps/api/app/services/conversation_service.py @@ -14,6 +14,7 @@ import re import random from collections.abc import AsyncGenerator +from datetime import datetime, timezone from uuid import UUID from sqlalchemy import or_, select @@ -22,6 +23,7 @@ from app.exceptions import NotFoundError from app.models import Conversation, Message, MessageGeneration, Notebook from app.trace import get_trace_id +from app.utils.async_tasks import create_logged_task logger = logging.getLogger(__name__) @@ -70,33 +72,42 @@ def _extract_genui_from_content(content: str) -> tuple[str, dict | None]: async def _load_user_memories_safely( + *args, + **kwargs, +): + raise RuntimeError("_load_user_memories_safely has been replaced by _load_prompt_context_safely") + + +async def _load_prompt_context_safely( db: AsyncSession, user_id: UUID, *, - current_query: str | None = None, - scene: str = "research", -) -> list[dict]: + current_query: str, + scene: str = "chat", + notebook_id: UUID | None = None, + conversation_id: UUID | None = None, + include_portrait: bool = False, +): """ - Load user memories behind a savepoint so schema drift or query failures - don't abort the outer conversation transaction. + Load the prompt context bundle behind a savepoint so memory sync / retrieval + failures do not abort the outer conversation transaction. """ - from app.agents.memory import build_memory_context, get_user_memories + from app.agents.memory import build_prompt_context_bundle, load_prompt_context try: async with db.begin_nested(): - if current_query is not None: - return await build_memory_context( - user_id, - current_query, - db, - top_k=5, - scene=scene, - ) - return await get_user_memories(user_id, db) + return await load_prompt_context( + user_id=user_id, + query=current_query, + db=db, + scene=scene, + notebook_id=notebook_id, + conversation_id=conversation_id, + include_portrait=include_portrait, + ) except Exception as exc: - mode = "build_memory_context" if current_query is not None else "get_user_memories" - logger.warning("%s failed: %s", mode, exc) - return [] + logger.warning("load_prompt_context failed: %s", exc) + return build_prompt_context_bundle(scene=scene) class ConversationService: @@ -201,10 +212,16 @@ async def send_message(self, conversation_id: UUID, content: str) -> Message: from app.agents.rag.graph_retrieval import graph_augmented_context from app.agents.rag.retrieval import retrieve_chunks from app.agents.writing.composer import compose_answer - from app.agents.memory import get_notebook_summary - user_memories = await _load_user_memories_safely(self.db, self.user_id) - notebook_summary = await get_notebook_summary(conv.notebook_id, self.db) + prompt_context = await _load_prompt_context_safely( + self.db, + self.user_id, + current_query=content, + scene="chat", + notebook_id=conv.notebook_id, + conversation_id=conv.id, + include_portrait=False, + ) if conv.notebook_id: chunks, graph_ctx = await asyncio.gather( retrieve_chunks( @@ -228,9 +245,7 @@ async def send_message(self, conversation_id: UUID, content: str) -> Message: content, chunks, history, - user_memories=user_memories, - notebook_summary=notebook_summary, - db=self.db, + prompt_context=prompt_context, extra_graph_context=graph_ctx or None, ) @@ -351,6 +366,48 @@ async def get_message_generation_status(self, generation_id: UUID) -> dict: "completed_at": generation.completed_at, } + async def cancel_message_generation(self, generation_id: UUID) -> None: + from app.agents.chat import cancel_message_generation_task + + generation = await self._get_owned_generation(generation_id) + if generation.status in {"done", "error", "cancelled"}: + return + + task = cancel_message_generation_task(str(generation.id)) + if task is not None: + try: + await task + except asyncio.CancelledError: + pass + await self.db.refresh(generation) + if generation.status == "cancelled": + return + + assistant_message = await self.db.get(Message, generation.assistant_message_id) + has_visible_output = bool( + (assistant_message.content if assistant_message else "") + or (assistant_message.reasoning if assistant_message else None) + or (assistant_message.citations if assistant_message else None) + or (assistant_message.agent_steps if assistant_message else None) + or (assistant_message.speed if assistant_message else None) + or (assistant_message.mind_map if assistant_message else None) + or (assistant_message.diagram if assistant_message else None) + or (assistant_message.mcp_result if assistant_message else None) + or (assistant_message.ui_elements if assistant_message else None) + ) + + generation.status = "cancelled" + generation.completed_at = datetime.now(timezone.utc) + generation.error_message = None + + if assistant_message is not None: + if has_visible_output: + assistant_message.status = "completed" + else: + await self.db.delete(assistant_message) + + await self.db.commit() + async def subscribe_message_generation( self, generation_id: UUID, @@ -391,36 +448,17 @@ async def _load_history(self, conversation_id: UUID) -> list[dict]: from app.agents.memory import get_conversation_summary, RAW_HISTORY_WINDOW summary_text = await get_conversation_summary(conversation_id, self.db) - - if summary_text: - result = await self.db.execute( - select(Message) - .where(Message.conversation_id == conversation_id) - .where(or_(Message.status != "streaming", Message.content != "")) - .order_by(Message.created_at.desc(), Message.role.asc()) - .limit(RAW_HISTORY_WINDOW) - ) - recent = list(reversed(result.scalars().all())) - history: list[dict] = [ - { - "role": "system", - "content": ( - "【对话历史摘要】以下是本次会话较早期的对话压缩摘要," - "请结合它理解用户的研究背景和上下文:\n\n" + summary_text - ), - } - ] - history.extend({"role": m.role, "content": m.content} for m in recent) - return history + limit = RAW_HISTORY_WINDOW if summary_text else 20 result = await self.db.execute( select(Message) .where(Message.conversation_id == conversation_id) .where(or_(Message.status != "streaming", Message.content != "")) - .order_by(Message.created_at.asc(), Message.role.desc()) - .limit(20) + .order_by(Message.created_at.desc(), Message.role.asc()) + .limit(limit) ) - return [{"role": m.role, "content": m.content} for m in result.scalars().all()] + recent = list(reversed(result.scalars().all())) + return [{"role": m.role, "content": m.content} for m in recent] # ── Post-chat background tasks ──────────────────────────────────────────── @@ -430,14 +468,34 @@ def _dispatch_post_chat_tasks( scene: str, user_memories: list[dict], ) -> None: - asyncio.create_task(self._extract_memories_safe(conversation_id)) - asyncio.create_task(self._reflect_safe(conversation_id, scene, user_memories)) - asyncio.create_task(self._compress_safe(conversation_id)) - asyncio.create_task(self._maybe_flush_diary(conversation_id)) + create_logged_task( + self._extract_memories_safe(conversation_id), + logger=logger, + description=f"extract memories for conversation {conversation_id}", + ) + create_logged_task( + self._reflect_safe(conversation_id, scene, user_memories), + logger=logger, + description=f"reflect on conversation {conversation_id}", + ) + create_logged_task( + self._compress_safe(conversation_id), + logger=logger, + description=f"compress conversation {conversation_id}", + ) + create_logged_task( + self._maybe_flush_diary(conversation_id), + logger=logger, + description=f"maybe flush diary for conversation {conversation_id}", + ) from app.config import settings if settings.memory_evaluation_sample_rate > 0 and random.random() < settings.memory_evaluation_sample_rate: - asyncio.create_task(self._evaluate_safe(conversation_id)) + create_logged_task( + self._evaluate_safe(conversation_id), + logger=logger, + description=f"evaluate memories for conversation {conversation_id}", + ) async def _extract_memories_safe(self, conversation_id: UUID) -> None: from app.agents.memory import extract_memories @@ -485,26 +543,9 @@ async def _maybe_flush_diary(self, conversation_id: UUID) -> None: if msg_count >= MEMORY_FLUSH_THRESHOLD: from app.workers.tasks import flush_conversation_to_diary flush_conversation_to_diary.delay(str(conversation_id)) - - from app.config import settings - if settings.memory_mode == "desktop": - from datetime import datetime, timezone - today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - asyncio.create_task(self._sync_diary_safe(today)) except Exception as exc: logger.warning("Diary flush error: %s", exc) - async def _sync_diary_safe(self, date_str: str) -> None: - from app.agents.memory.file_storage import sync_diary_to_db - from app.database import AsyncSessionLocal - try: - async with AsyncSessionLocal() as session: - synced = await sync_diary_to_db(self.user_id, date_str, session) - if synced: - await session.commit() - except Exception as exc: - logger.warning("Diary sync error: %s", exc) - async def _evaluate_safe(self, conversation_id: UUID) -> None: from app.agents.research.evaluation import evaluate_conversation from app.database import AsyncSessionLocal diff --git a/apps/api/app/services/desktop_agent_service.py b/apps/api/app/services/desktop_agent_service.py new file mode 100644 index 0000000..4dfae2a --- /dev/null +++ b/apps/api/app/services/desktop_agent_service.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from uuid import UUID + +from app.services.desktop_runtime_service import desktop_job_manager, desktop_state_store + + +class DesktopAgentService: + def __init__(self) -> None: + desktop_job_manager.ensure_started() + + @staticmethod + def _normalize_user_id(user_id: UUID | str) -> str: + return str(user_id) + + def list_jobs(self, *, user_id: UUID | str) -> dict: + return { + "items": desktop_state_store.list_jobs( + user_id=self._normalize_user_id(user_id) + ) + } + + def cancel_job(self, *, user_id: UUID | str, job_id: str) -> dict: + return desktop_job_manager.cancel_job( + user_id=self._normalize_user_id(user_id), + job_id=job_id, + ) diff --git a/apps/api/app/services/desktop_chat_service.py b/apps/api/app/services/desktop_chat_service.py new file mode 100644 index 0000000..1d80586 --- /dev/null +++ b/apps/api/app/services/desktop_chat_service.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.desktop_knowledge_service import DesktopKnowledgeService + + +class DesktopChatService: + def __init__(self, db: AsyncSession, user_id: UUID | str) -> None: + self.db = db + self.user_id = user_id if isinstance(user_id, UUID) else UUID(str(user_id)) + self.knowledge_service = DesktopKnowledgeService(db, self.user_id) + + @staticmethod + def _compress_text(text: str, limit: int = 200) -> str: + condensed = " ".join(text.split()) + if len(condensed) <= limit: + return condensed + return condensed[: limit - 3].rstrip() + "..." + + @staticmethod + def _format_location(metadata: dict | None) -> str: + if not metadata: + return "" + parts: list[str] = [] + page = metadata.get("page") + if page: + parts.append(f"第{page}页") + heading = metadata.get("heading") or metadata.get("section") + if heading: + parts.append(str(heading)) + return f"({' · '.join(parts)})" if parts else "" + + async def answer_locally( + self, + *, + query: str, + notebook_id: UUID | str | None = None, + source_id: UUID | str | None = None, + limit: int = 5, + ) -> dict: + search = await self.knowledge_service.search_local( + query=query, + notebook_id=notebook_id, + source_id=source_id, + limit=limit, + ) + items = search["items"] + if not items: + return { + "mode": "offline_cache", + "query": query, + "answer": ( + f"我没有在本地知识库里找到与“{query}”直接相关的片段。" + "可以换一个更具体的关键词,或者先导入相关资料后再试。" + ), + "citations": [], + } + + top_items = items[: min(3, len(items))] + lines = [f"我在本地知识库里找到 {len(top_items)} 条与“{query}”最相关的内容:"] + citations: list[dict] = [] + for index, item in enumerate(top_items, start=1): + location = self._format_location(item.get("metadata")) + source_title = item.get("source_title") or "未命名资料" + excerpt = self._compress_text(item.get("excerpt") or item.get("content") or "") + lines.append(f"{index}. {source_title}{location}:{excerpt}") + citations.append( + { + "source_id": item["source_id"], + "chunk_id": item["chunk_id"], + "source_title": source_title, + "excerpt": excerpt, + "metadata": item.get("metadata"), + } + ) + lines.append("以上内容基于本地离线检索结果整理,未调用云端模型。") + + return { + "mode": "offline_cache", + "query": query, + "answer": "\n".join(lines), + "citations": citations, + } diff --git a/apps/api/app/services/desktop_knowledge_service.py b/apps/api/app/services/desktop_knowledge_service.py new file mode 100644 index 0000000..3ffafc0 --- /dev/null +++ b/apps/api/app/services/desktop_knowledge_service.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import AsyncSessionLocal +from app.exceptions import BadRequestError, NotFoundError +from app.models import Chunk, Notebook, Source +from app.services.desktop_runtime_service import ( + SUPPORTED_WATCH_EXTENSIONS, + compute_file_sha256, + desktop_job_manager, + desktop_state_store, + emit_desktop_event, +) +from app.services.source_service import SourceService + + +class DesktopKnowledgeService: + def __init__(self, db: AsyncSession | None, user_id: UUID | str) -> None: + self.db = db + self.user_id = user_id if isinstance(user_id, UUID) else UUID(str(user_id)) + desktop_job_manager.ensure_started() + + @property + def user_id_str(self) -> str: + return str(self.user_id) + + def list_watch_folders(self) -> dict: + return {"items": desktop_state_store.list_watch_folders(user_id=self.user_id_str)} + + def create_watch_folder(self, *, path: str) -> dict: + try: + return desktop_state_store.create_watch_folder(user_id=self.user_id_str, path=path) + except FileNotFoundError as exc: + raise BadRequestError("目录不存在") from exc + except NotADirectoryError as exc: + raise BadRequestError("只能注册目录,不能注册文件") from exc + except Exception as exc: + if "UNIQUE constraint failed" in str(exc): + raise BadRequestError("该目录已注册为监听目录") from exc + raise + + def delete_watch_folder(self, *, folder_id: str) -> None: + deleted = desktop_state_store.delete_watch_folder( + user_id=self.user_id_str, + folder_id=folder_id, + ) + if not deleted: + raise NotFoundError("监听目录不存在") + + def list_recent_imports(self) -> dict: + return {"items": desktop_state_store.list_recent_imports(user_id=self.user_id_str)} + + def inspect_local_file( + self, + *, + path: str, + sha256: str | None = None, + ) -> dict: + normalized = str(Path(path).expanduser().resolve()) + try: + return desktop_state_store.inspect_local_file( + user_id=self.user_id_str, + path=normalized, + sha256=sha256, + ) + except FileNotFoundError as exc: + raise NotFoundError("文件不存在") from exc + except IsADirectoryError as exc: + raise BadRequestError("暂不支持导入目录") from exc + + async def import_watch_folder_path(self, *, path: str) -> dict: + normalized = str(Path(path).expanduser().resolve()) + folder = desktop_state_store.find_matching_watch_folder( + user_id=self.user_id_str, + path=normalized, + ) + if folder is None: + raise BadRequestError("文件不在已注册的监听目录内") + + file_path = Path(normalized) + if not file_path.exists(): + desktop_state_store.touch_watch_folder_error( + user_id=self.user_id_str, + path=normalized, + error="文件不存在", + ) + raise NotFoundError("文件不存在") + if not file_path.is_file(): + raise BadRequestError("暂不支持导入目录") + if file_path.suffix.lower() not in SUPPORTED_WATCH_EXTENSIONS: + raise BadRequestError("该文件类型暂不支持自动导入") + if not desktop_state_store.should_process_watch_path( + user_id=self.user_id_str, + path=normalized, + ): + return {"state": "skipped", "path": normalized} + + digest = compute_file_sha256(normalized) + inspection = desktop_state_store.inspect_local_file( + user_id=self.user_id_str, + path=normalized, + sha256=digest, + ) + if inspection["state"] in {"unchanged", "duplicate"}: + desktop_state_store.record_import( + user_id=self.user_id_str, + path=normalized, + source_id=inspection.get("source_id"), + title=inspection.get("matched_title") or file_path.name, + sha256=digest, + ) + emit_desktop_event( + "import.result", + { + "path": normalized, + "source_id": inspection.get("source_id"), + "state": inspection["state"], + }, + ) + return { + "state": inspection["state"], + "path": normalized, + "source_id": inspection.get("source_id"), + } + + try: + async with AsyncSessionLocal() as db: + source = await SourceService(db, self.user_id).import_global_source_path( + normalized, + sha256=digest, + ) + except Exception as exc: + desktop_state_store.touch_watch_folder_error( + user_id=self.user_id_str, + path=normalized, + error=str(exc), + ) + emit_desktop_event( + "import.failed", + {"path": normalized, "state": "failed", "error": str(exc)}, + ) + raise + + emit_desktop_event( + "import.result", + { + "path": normalized, + "source_id": str(source.id), + "state": "queued", + }, + ) + return { + "state": "queued", + "path": normalized, + "source_id": str(source.id), + } + + def _require_db(self) -> AsyncSession: + if self.db is None: + raise RuntimeError("Desktop knowledge service requires a database session") + return self.db + + async def ensure_local_index_warmed(self) -> int: + if desktop_state_store.count_local_chunks(user_id=self.user_id_str) > 0: + return 0 + + db = self._require_db() + result = await db.execute( + select(Source.id) + .join(Notebook, Source.notebook_id == Notebook.id) + .where( + Notebook.user_id == self.user_id, + Source.status == "indexed", + ) + .order_by(Source.updated_at.desc()) + ) + source_ids = [row[0] for row in result.all()] + synced = 0 + for source_id in source_ids: + synced += await self.sync_source_chunks(source_id) + return synced + + async def sync_source_chunks(self, source_id: UUID | str) -> int: + db = self._require_db() + source_uuid = source_id if isinstance(source_id, UUID) else UUID(str(source_id)) + + source_result = await db.execute( + select(Source) + .join(Notebook, Source.notebook_id == Notebook.id) + .where(Source.id == source_uuid, Notebook.user_id == self.user_id) + ) + source = source_result.scalar_one_or_none() + if source is None: + raise NotFoundError("资源不存在") + + chunk_result = await db.execute( + select(Chunk) + .where(Chunk.source_id == source.id) + .order_by(Chunk.chunk_index.asc()) + ) + chunks = list(chunk_result.scalars().all()) + desktop_state_store.sync_source_chunks( + user_id=self.user_id_str, + source_id=str(source.id), + notebook_id=str(source.notebook_id), + source_title=source.title, + source_type=source.type, + chunks=[ + { + "chunk_id": str(chunk.id), + "chunk_index": int(chunk.chunk_index or 0), + "content": chunk.content, + "metadata": chunk.metadata_, + } + for chunk in chunks + ], + ) + return len(chunks) + + async def search_local( + self, + *, + query: str, + notebook_id: UUID | str | None = None, + source_id: UUID | str | None = None, + limit: int = 5, + ) -> dict: + await self.ensure_local_index_warmed() + items = desktop_state_store.search_local_chunks( + user_id=self.user_id_str, + query=query, + notebook_id=str(notebook_id) if notebook_id is not None else None, + source_id=str(source_id) if source_id is not None else None, + limit=limit, + ) + return { + "query": query, + "mode": "fts5", + "items": items, + } diff --git a/apps/api/app/services/desktop_memory_service.py b/apps/api/app/services/desktop_memory_service.py new file mode 100644 index 0000000..33df5d3 --- /dev/null +++ b/apps/api/app/services/desktop_memory_service.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +from app.config import settings + + +class DesktopMemoryService: + @staticmethod + def get_runtime_memory_status() -> dict: + memory_dir = settings.memory_dir.strip() + resolved_memory_dir = ( + Path(memory_dir).expanduser().resolve() + if memory_dir + else (Path.home() / ".lyranote" / "memory").resolve() + ) + return { + "memory_mode": settings.memory_mode, + "memory_dir": str(resolved_memory_dir), + "is_desktop_runtime": settings.is_desktop_runtime, + } diff --git a/apps/api/app/services/desktop_runtime_service.py b/apps/api/app/services/desktop_runtime_service.py new file mode 100644 index 0000000..bbee650 --- /dev/null +++ b/apps/api/app/services/desktop_runtime_service.py @@ -0,0 +1,1043 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import sqlite3 +import threading +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import UUID + +from app.config import settings +from app.database import AsyncSessionLocal + +SUPPORTED_WATCH_EXTENSIONS = {".pdf", ".md", ".txt", ".docx"} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def emit_desktop_event(event_type: str, payload: dict[str, Any]) -> None: + if not settings.is_desktop_runtime or not settings.desktop_stdout_events: + return + + print( + json.dumps( + { + "type": event_type, + "payload": payload, + "occurred_at": _now_iso(), + }, + ensure_ascii=False, + ), + flush=True, + ) + + +@dataclass +class DesktopJobRecord: + id: str + user_id: str + kind: str + state: str + label: str + progress: int + message: str | None + resource_id: str | None + payload_json: str + created_at: str + updated_at: str + + +class DesktopStateStore: + def __init__(self) -> None: + self._db_path: Path | None = None + self._init_lock = threading.Lock() + self._ensure_initialized() + + @property + def db_path(self) -> Path: + return self._ensure_initialized() + + def _target_db_path(self) -> Path: + return (settings.desktop_state_dir / "runtime-state.sqlite3").resolve() + + def _ensure_initialized(self) -> Path: + target = self._target_db_path() + with self._init_lock: + if self._db_path == target: + return target + target.parent.mkdir(parents=True, exist_ok=True) + self._init_db(target) + self._db_path = target + return target + + def _connect(self, db_path: Path | None = None) -> sqlite3.Connection: + if db_path is None: + target = self._target_db_path() + db_path = target if self._db_path == target else self._ensure_initialized() + conn = sqlite3.connect(db_path, timeout=30) + conn.row_factory = sqlite3.Row + return conn + + def _init_db(self, db_path: Path) -> None: + with self._connect(db_path) as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS desktop_jobs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + state TEXT NOT NULL, + label TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + message TEXT, + resource_id TEXT, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_desktop_jobs_user_updated + ON desktop_jobs(user_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_desktop_jobs_state_created + ON desktop_jobs(state, created_at ASC); + + CREATE TABLE IF NOT EXISTS watch_folders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + path TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_synced_at TEXT, + last_error TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + UNIQUE(user_id, path) + ); + + CREATE TABLE IF NOT EXISTS recent_imports ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + path TEXT NOT NULL, + source_id TEXT, + title TEXT, + imported_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_recent_imports_user_time + ON recent_imports(user_id, imported_at DESC); + + CREATE TABLE IF NOT EXISTS watched_file_state ( + user_id TEXT NOT NULL, + path TEXT NOT NULL, + size INTEGER NOT NULL, + mtime_ns INTEGER NOT NULL, + source_id TEXT, + title TEXT, + sha256 TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY(user_id, path) + ); + """ + ) + self._ensure_column(conn, "watched_file_state", "title", "TEXT") + self._ensure_column(conn, "watched_file_state", "sha256", "TEXT") + conn.executescript( + """ + CREATE INDEX IF NOT EXISTS idx_watched_file_state_user_sha256 + ON watched_file_state(user_id, sha256) + ; + + CREATE VIRTUAL TABLE IF NOT EXISTS desktop_chunk_fts USING fts5( + chunk_id UNINDEXED, + user_id UNINDEXED, + source_id UNINDEXED, + notebook_id UNINDEXED, + source_title UNINDEXED, + source_type UNINDEXED, + chunk_index UNINDEXED, + metadata_json UNINDEXED, + content, + tokenize='unicode61 remove_diacritics 2' + ); + """ + ) + + @staticmethod + def _ensure_column( + conn: sqlite3.Connection, + table: str, + column: str, + definition: str, + ) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + if any(str(row["name"]) == column for row in rows): + return + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") + + def list_jobs(self, *, user_id: str, limit: int = 50) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, kind, state, label, progress, message, resource_id, created_at, updated_at + FROM desktop_jobs + WHERE user_id = ? + ORDER BY updated_at DESC + LIMIT ? + """, + (user_id, limit), + ).fetchall() + return [dict(row) for row in rows] + + def list_recent_imports( + self, + *, + user_id: str, + limit: int = 10, + ) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT path, source_id, title, imported_at + FROM recent_imports + WHERE user_id = ? + ORDER BY imported_at DESC + LIMIT ? + """, + (user_id, limit), + ).fetchall() + return [dict(row) for row in rows] + + def create_job( + self, + *, + user_id: str, + kind: str, + label: str, + resource_id: str | None, + payload: dict[str, Any], + ) -> dict[str, Any]: + job_id = str(uuid.uuid4()) + now = _now_iso() + payload_json = json.dumps(payload, ensure_ascii=False) + with self._connect() as conn: + conn.execute( + """ + INSERT INTO desktop_jobs ( + id, user_id, kind, state, label, progress, message, resource_id, payload_json, created_at, updated_at + ) VALUES (?, ?, ?, 'queued', ?, 0, ?, ?, ?, ?, ?) + """, + ( + job_id, + user_id, + kind, + label, + "已加入桌面队列", + resource_id, + payload_json, + now, + now, + ), + ) + return self.get_job(job_id) + + def get_job(self, job_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute( + """ + SELECT id, user_id, kind, state, label, progress, message, resource_id, payload_json, created_at, updated_at + FROM desktop_jobs + WHERE id = ? + """, + (job_id,), + ).fetchone() + if row is None: + raise KeyError(job_id) + return dict(row) + + def next_queued_job(self) -> dict[str, Any] | None: + with self._connect() as conn: + row = conn.execute( + """ + SELECT id + FROM desktop_jobs + WHERE state = 'queued' + ORDER BY created_at ASC + LIMIT 1 + """ + ).fetchone() + if row is None: + return None + return self.get_job(str(row["id"])) + + def update_job( + self, + job_id: str, + *, + state: str, + progress: int | None = None, + message: str | None = None, + ) -> dict[str, Any]: + now = _now_iso() + current = self.get_job(job_id) + with self._connect() as conn: + conn.execute( + """ + UPDATE desktop_jobs + SET state = ?, progress = ?, message = ?, updated_at = ? + WHERE id = ? + """, + ( + state, + current["progress"] if progress is None else progress, + current["message"] if message is None else message, + now, + job_id, + ), + ) + return self.get_job(job_id) + + def cancel_job(self, *, user_id: str, job_id: str) -> dict[str, Any]: + job = self.get_job(job_id) + if job["user_id"] != user_id: + return {"cancelled": False, "reason": "任务不存在"} + if job["state"] == "queued": + self.update_job(job_id, state="cancelled", progress=0, message="任务已取消") + return {"cancelled": True, "reason": None} + if job["state"] in {"succeeded", "failed", "cancelled"}: + return {"cancelled": False, "reason": "任务已经结束"} + return {"cancelled": False, "reason": "运行中的任务暂不支持中断"} + + def list_watch_folders(self, *, user_id: str) -> list[dict[str, Any]]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT id, path, name, created_at, last_synced_at, last_error, is_active + FROM watch_folders + WHERE user_id = ? + ORDER BY created_at DESC + """, + (user_id,), + ).fetchall() + return [ + { + **dict(row), + "is_active": bool(row["is_active"]), + } + for row in rows + ] + + def create_watch_folder(self, *, user_id: str, path: str) -> dict[str, Any]: + folder = Path(path).expanduser().resolve() + if not folder.exists(): + raise FileNotFoundError("目录不存在") + if not folder.is_dir(): + raise NotADirectoryError("只能注册目录,不能注册文件") + + now = _now_iso() + row_id = str(uuid.uuid4()) + with self._connect() as conn: + conn.execute( + """ + INSERT INTO watch_folders ( + id, user_id, path, name, created_at, last_synced_at, last_error, is_active + ) VALUES (?, ?, ?, ?, ?, NULL, NULL, 1) + """, + (row_id, user_id, str(folder), folder.name or str(folder), now), + ) + return self.get_watch_folder(user_id=user_id, folder_id=row_id) + + def get_watch_folder(self, *, user_id: str, folder_id: str) -> dict[str, Any]: + with self._connect() as conn: + row = conn.execute( + """ + SELECT id, path, name, created_at, last_synced_at, last_error, is_active + FROM watch_folders + WHERE user_id = ? AND id = ? + """, + (user_id, folder_id), + ).fetchone() + if row is None: + raise KeyError(folder_id) + return { + **dict(row), + "is_active": bool(row["is_active"]), + } + + def delete_watch_folder(self, *, user_id: str, folder_id: str) -> bool: + with self._connect() as conn: + cursor = conn.execute( + "DELETE FROM watch_folders WHERE user_id = ? AND id = ?", + (user_id, folder_id), + ) + return cursor.rowcount > 0 + + def find_matching_watch_folder(self, *, user_id: str, path: str) -> dict[str, Any] | None: + normalized = str(Path(path).expanduser().resolve()) + for item in self.list_watch_folders(user_id=user_id): + folder_path = item["path"] + if normalized == folder_path or normalized.startswith(f"{folder_path}{os.sep}"): + return item + return None + + def should_process_watch_path(self, *, user_id: str, path: str) -> bool: + normalized = str(Path(path).expanduser().resolve()) + stat = os.stat(normalized) + with self._connect() as conn: + row = conn.execute( + """ + SELECT size, mtime_ns + FROM watched_file_state + WHERE user_id = ? AND path = ? + """, + (user_id, normalized), + ).fetchone() + if row is None: + return True + return not ( + int(row["size"]) == stat.st_size and int(row["mtime_ns"]) == stat.st_mtime_ns + ) + + def inspect_local_file( + self, + *, + user_id: str, + path: str, + sha256: str | None = None, + ) -> dict[str, Any]: + normalized = str(Path(path).expanduser().resolve()) + file_path = Path(normalized) + if not file_path.exists(): + raise FileNotFoundError(normalized) + if not file_path.is_file(): + raise IsADirectoryError(normalized) + + stat = os.stat(normalized) + with self._connect() as conn: + path_row = conn.execute( + """ + SELECT path, size, mtime_ns, source_id, title, sha256, updated_at + FROM watched_file_state + WHERE user_id = ? AND path = ? + """, + (user_id, normalized), + ).fetchone() + + if path_row is not None: + if sha256 and path_row["sha256"] and path_row["sha256"] == sha256: + return { + "state": "unchanged", + "path": normalized, + "source_id": path_row["source_id"], + "matched_path": path_row["path"], + "matched_title": path_row["title"], + "sha256": path_row["sha256"], + } + if ( + not sha256 + and int(path_row["size"]) == stat.st_size + and int(path_row["mtime_ns"]) == stat.st_mtime_ns + ): + return { + "state": "unchanged", + "path": normalized, + "source_id": path_row["source_id"], + "matched_path": path_row["path"], + "matched_title": path_row["title"], + "sha256": path_row["sha256"], + } + + if sha256: + digest_row = conn.execute( + """ + SELECT path, source_id, title, sha256 + FROM watched_file_state + WHERE user_id = ? AND sha256 = ? AND path != ? + ORDER BY updated_at DESC + LIMIT 1 + """, + (user_id, sha256, normalized), + ).fetchone() + if digest_row is not None: + return { + "state": "duplicate", + "path": normalized, + "source_id": digest_row["source_id"], + "matched_path": digest_row["path"], + "matched_title": digest_row["title"], + "sha256": digest_row["sha256"], + } + + if path_row is not None: + return { + "state": "updated", + "path": normalized, + "source_id": path_row["source_id"], + "matched_path": path_row["path"], + "matched_title": path_row["title"], + "sha256": sha256, + } + + return { + "state": "new", + "path": normalized, + "source_id": None, + "matched_path": None, + "matched_title": None, + "sha256": sha256, + } + + def record_import( + self, + *, + user_id: str, + path: str, + source_id: str | None, + title: str | None, + sha256: str | None = None, + ) -> None: + normalized = str(Path(path).expanduser().resolve()) + now = _now_iso() + stat = os.stat(normalized) + with self._connect() as conn: + conn.execute( + """ + INSERT INTO recent_imports (id, user_id, path, source_id, title, imported_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (str(uuid.uuid4()), user_id, normalized, source_id, title, now), + ) + conn.execute( + """ + INSERT INTO watched_file_state ( + user_id, path, size, mtime_ns, source_id, title, sha256, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, path) + DO UPDATE SET + size = excluded.size, + mtime_ns = excluded.mtime_ns, + source_id = excluded.source_id, + title = excluded.title, + sha256 = excluded.sha256, + updated_at = excluded.updated_at + """, + ( + user_id, + normalized, + stat.st_size, + stat.st_mtime_ns, + source_id, + title, + sha256, + now, + ), + ) + conn.execute( + """ + DELETE FROM recent_imports + WHERE user_id = ? + AND id NOT IN ( + SELECT id FROM recent_imports + WHERE user_id = ? + ORDER BY imported_at DESC + LIMIT 20 + ) + """, + (user_id, user_id), + ) + + self.touch_watch_folder_success(user_id=user_id, path=normalized) + + def count_local_chunks(self, *, user_id: str) -> int: + with self._connect() as conn: + row = conn.execute( + "SELECT COUNT(*) AS total FROM desktop_chunk_fts WHERE user_id = ?", + (user_id,), + ).fetchone() + return int(row["total"] or 0) if row is not None else 0 + + def sync_source_chunks( + self, + *, + user_id: str, + source_id: str, + notebook_id: str, + source_title: str | None, + source_type: str, + chunks: list[dict[str, Any]], + ) -> None: + with self._connect() as conn: + conn.execute( + "DELETE FROM desktop_chunk_fts WHERE user_id = ? AND source_id = ?", + (user_id, source_id), + ) + for chunk in chunks: + conn.execute( + """ + INSERT INTO desktop_chunk_fts ( + chunk_id, + user_id, + source_id, + notebook_id, + source_title, + source_type, + chunk_index, + metadata_json, + content + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + chunk["chunk_id"], + user_id, + source_id, + notebook_id, + source_title, + source_type, + chunk["chunk_index"], + json.dumps(chunk.get("metadata") or {}, ensure_ascii=False), + chunk["content"], + ), + ) + + @staticmethod + def _normalize_fts_query(query: str) -> str: + cleaned = ( + query.replace('"', " ") + .replace("'", " ") + .replace(":", " ") + .replace("*", " ") + .replace("(", " ") + .replace(")", " ") + ) + tokens = [token.strip() for token in cleaned.split() if token.strip()] + return " ".join(tokens) if tokens else query.strip() + + def search_local_chunks( + self, + *, + user_id: str, + query: str, + notebook_id: str | None = None, + source_id: str | None = None, + limit: int = 5, + ) -> list[dict[str, Any]]: + raw_query = query.strip() + if not raw_query: + return [] + + clauses = ["user_id = ?"] + params: list[Any] = [user_id] + if notebook_id: + clauses.append("notebook_id = ?") + params.append(notebook_id) + if source_id: + clauses.append("source_id = ?") + params.append(source_id) + + filter_sql = " AND ".join(clauses) + fts_query = self._normalize_fts_query(raw_query) + + with self._connect() as conn: + try: + rows = conn.execute( + f""" + SELECT + chunk_id, + source_id, + notebook_id, + source_title, + source_type, + chunk_index, + metadata_json, + content, + snippet(desktop_chunk_fts, 8, '<<', '>>', '…', 18) AS excerpt, + bm25(desktop_chunk_fts) AS rank + FROM desktop_chunk_fts + WHERE desktop_chunk_fts MATCH ? AND {filter_sql} + ORDER BY rank ASC, chunk_index ASC + LIMIT ? + """, + [fts_query, *params, limit], + ).fetchall() + except sqlite3.OperationalError: + rows = [] + + if not rows: + like_pattern = f"%{raw_query}%" + rows = conn.execute( + f""" + SELECT + chunk_id, + source_id, + notebook_id, + source_title, + source_type, + chunk_index, + metadata_json, + content, + substr(content, 1, 220) AS excerpt, + NULL AS rank + FROM desktop_chunk_fts + WHERE {filter_sql} + AND ( + content LIKE ? + OR COALESCE(source_title, '') LIKE ? + ) + ORDER BY chunk_index ASC + LIMIT ? + """, + [*params, like_pattern, like_pattern, limit], + ).fetchall() + + items: list[dict[str, Any]] = [] + for row in rows: + metadata_json = row["metadata_json"] + try: + metadata = json.loads(metadata_json) if metadata_json else None + except json.JSONDecodeError: + metadata = None + excerpt = (row["excerpt"] or row["content"][:220]).replace("<<", "").replace(">>", "") + rank = row["rank"] + items.append( + { + "chunk_id": str(row["chunk_id"]), + "source_id": str(row["source_id"]), + "notebook_id": str(row["notebook_id"]), + "source_title": row["source_title"], + "source_type": row["source_type"], + "chunk_index": int(row["chunk_index"]), + "content": row["content"], + "excerpt": excerpt, + "rank": float(rank) if rank is not None else None, + "metadata": metadata, + } + ) + return items + + def touch_watch_folder_success(self, *, user_id: str, path: str) -> None: + match = self.find_matching_watch_folder(user_id=user_id, path=path) + if not match: + return + with self._connect() as conn: + conn.execute( + """ + UPDATE watch_folders + SET last_synced_at = ?, last_error = NULL + WHERE user_id = ? AND id = ? + """, + (_now_iso(), user_id, match["id"]), + ) + + def touch_watch_folder_error(self, *, user_id: str, path: str, error: str) -> None: + match = self.find_matching_watch_folder(user_id=user_id, path=path) + if not match: + return + with self._connect() as conn: + conn.execute( + """ + UPDATE watch_folders + SET last_error = ? + WHERE user_id = ? AND id = ? + """, + (error, user_id, match["id"]), + ) + + +def compute_file_sha256(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +class DesktopJobManager: + def __init__(self, store: DesktopStateStore) -> None: + self._store = store + self._wake = threading.Event() + self._lock = threading.Lock() + self._thread: threading.Thread | None = None + self._running_job_id: str | None = None + + def ensure_started(self) -> None: + if not settings.is_desktop_runtime: + return + with self._lock: + if self._thread and self._thread.is_alive(): + return + self._thread = threading.Thread( + target=self._loop, + name="desktop-job-runner", + daemon=True, + ) + self._thread.start() + + def enqueue_source_ingest( + self, + *, + user_id: str, + source_id: str, + trace_id: str, + run_id: str, + kind: str, + label: str, + chunk_size: int | None, + chunk_overlap: int | None, + splitter_type: str | None, + separators: list[str] | None, + min_chunk_size: int | None, + ) -> str: + self.ensure_started() + job = self._store.create_job( + user_id=user_id, + kind=kind, + label=label, + resource_id=source_id, + payload={ + "source_id": source_id, + "trace_id": trace_id, + "run_id": run_id, + "chunk_size": chunk_size, + "chunk_overlap": chunk_overlap, + "splitter_type": splitter_type, + "separators": separators, + "min_chunk_size": min_chunk_size, + }, + ) + emit_desktop_event( + "job.progress", + { + "id": job["id"], + "kind": kind, + "state": "queued", + "progress": 0, + "message": job["message"], + "resource_id": source_id, + }, + ) + self._wake.set() + return str(job["id"]) + + def cancel_job(self, *, user_id: str, job_id: str) -> dict[str, Any]: + result = self._store.cancel_job(user_id=user_id, job_id=job_id) + if result["cancelled"]: + job = self._store.get_job(job_id) + emit_desktop_event( + "job.progress", + { + "id": job_id, + "kind": job["kind"], + "state": "cancelled", + "progress": 0, + "message": "任务已取消", + "resource_id": job["resource_id"], + }, + ) + return result + + def _loop(self) -> None: + while True: + job = self._store.next_queued_job() + if job is None: + self._wake.wait(timeout=1.0) + self._wake.clear() + continue + + job_id = str(job["id"]) + self._running_job_id = job_id + running = self._store.update_job( + job_id, + state="running", + progress=15, + message="正在执行桌面索引任务", + ) + emit_desktop_event( + "job.progress", + { + "id": job_id, + "kind": running["kind"], + "state": "running", + "progress": running["progress"], + "message": running["message"], + "resource_id": running["resource_id"], + }, + ) + + try: + asyncio.run(self._process_job(running)) + except Exception as error: # pragma: no cover - worker safety net + failed = self._store.update_job( + job_id, + state="failed", + progress=100, + message=str(error), + ) + emit_desktop_event( + "job.failed", + { + "id": job_id, + "kind": failed["kind"], + "state": failed["state"], + "progress": failed["progress"], + "message": failed["message"], + "resource_id": failed["resource_id"], + }, + ) + emit_desktop_event( + "import.failed", + { + "job_id": job_id, + "source_id": failed["resource_id"], + "state": "failed", + "error": failed["message"], + }, + ) + finally: + self._running_job_id = None + + async def _process_job(self, job: dict[str, Any]) -> None: + from app.agents.rag.ingestion import ingest + from app.models import Source + from app.services.monitoring_service import ( + finish_observability_run, + get_or_create_source_ingest_run, + ) + from app.services.desktop_knowledge_service import DesktopKnowledgeService + from app.workers.tasks.ingestion import _mark_source_failed + from app.services.monitoring_service import bind_trace_and_run, reset_trace_and_run + from sqlalchemy import select + + payload = json.loads(job["payload_json"]) + source_id = str(payload["source_id"]) + trace_id = payload.get("trace_id") + run_id = payload.get("run_id") + chunk_size = payload.get("chunk_size") or 512 + chunk_overlap = payload.get("chunk_overlap") or 64 + splitter_type = payload.get("splitter_type") or "recursive" + separators = payload.get("separators") + min_chunk_size = payload.get("min_chunk_size") or 50 + + async with AsyncSessionLocal() as db: + trace_token = None + run_token = None + try: + source_result = await db.execute( + select(Source).where(Source.id == UUID(source_id)) + ) + source = source_result.scalar_one_or_none() + if source is None: + raise ValueError(f"Source {source_id} not found") + + observability_run = await get_or_create_source_ingest_run( + db, + source_id=source.id, + notebook_id=source.notebook_id, + user_id=UUID(str(job["user_id"])), + trace_id=trace_id, + run_id=UUID(run_id) if run_id else None, + metadata={"origin": "desktop_runtime"}, + ) + trace_token, run_token = bind_trace_and_run(observability_run.trace_id, observability_run.id) + await ingest( + source_id, + db, + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + splitter_type=splitter_type, + separators=separators, + min_chunk_size=min_chunk_size, + ) + await db.commit() + source_status = source.status + if source_status != "indexed": + await finish_observability_run( + db, + observability_run, + status="failed", + metadata={"source_status": source_status}, + error_message=source.summary or "source ingest failed", + ) + await db.commit() + raise RuntimeError(source.summary or "source ingest failed") + await DesktopKnowledgeService( + db, + UUID(str(job["user_id"])), + ).sync_source_chunks(source_id) + await finish_observability_run( + db, + observability_run, + status="succeeded", + metadata={"source_status": source_status}, + error_message=None, + ) + await db.commit() + except Exception as error: + await db.rollback() + async with AsyncSessionLocal() as db2: + result = await db2.execute( + select(Source).where(Source.id == UUID(source_id)) + ) + source = result.scalar_one_or_none() + observability_run = None + if source is not None: + observability_run = await get_or_create_source_ingest_run( + db2, + source_id=source.id, + notebook_id=source.notebook_id, + user_id=UUID(str(job["user_id"])), + trace_id=trace_id, + run_id=UUID(run_id) if run_id else None, + metadata={"origin": "desktop_runtime"}, + ) + if source is not None: + _mark_source_failed(source, str(error)) + if observability_run is not None: + await finish_observability_run( + db2, + observability_run, + status="failed", + metadata={"source_status": "failed"}, + error_message=str(error), + ) + await db2.commit() + raise + finally: + if trace_token is not None and run_token is not None: + reset_trace_and_run(trace_token, run_token) + + completed = self._store.update_job( + str(job["id"]), + state="succeeded", + progress=100, + message="桌面索引任务已完成", + ) + emit_desktop_event( + "job.completed", + { + "id": completed["id"], + "kind": completed["kind"], + "state": completed["state"], + "progress": completed["progress"], + "message": completed["message"], + "resource_id": completed["resource_id"], + }, + ) + emit_desktop_event( + "import.result", + { + "job_id": completed["id"], + "source_id": completed["resource_id"], + "state": "succeeded", + }, + ) + + +desktop_state_store = DesktopStateStore() +desktop_job_manager = DesktopJobManager(desktop_state_store) diff --git a/apps/api/app/services/desktop_service.py b/apps/api/app/services/desktop_service.py new file mode 100644 index 0000000..29b805a --- /dev/null +++ b/apps/api/app/services/desktop_service.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from uuid import UUID + +from app.config import settings +from app.services.desktop_agent_service import DesktopAgentService +from app.services.desktop_knowledge_service import DesktopKnowledgeService +from app.services.desktop_memory_service import DesktopMemoryService + + +class DesktopService: + def __init__(self) -> None: + self.agent_service = DesktopAgentService() + self.memory_service = DesktopMemoryService() + + def get_runtime_status(self) -> dict: + memory_status = self.memory_service.get_runtime_memory_status() + return { + "profile": settings.runtime_profile, + "health": "ok", + "database_url": settings.database_url, + "memory_mode": memory_status["memory_mode"], + "memory_dir": memory_status["memory_dir"], + "stdout_events": settings.desktop_stdout_events, + } + + def list_jobs(self, *, user_id: str) -> dict: + return self.agent_service.list_jobs(user_id=user_id) + + def cancel_job(self, *, user_id: str, job_id: str) -> dict: + return self.agent_service.cancel_job(user_id=user_id, job_id=job_id) + + def _knowledge_service(self, user_id: str) -> DesktopKnowledgeService: + return DesktopKnowledgeService(None, UUID(user_id)) + + def list_watch_folders(self, *, user_id: str) -> dict: + return self._knowledge_service(user_id).list_watch_folders() + + def list_recent_imports(self, *, user_id: str) -> dict: + return self._knowledge_service(user_id).list_recent_imports() + + def inspect_local_file( + self, + *, + user_id: str, + path: str, + sha256: str | None = None, + ) -> dict: + return self._knowledge_service(user_id).inspect_local_file(path=path, sha256=sha256) + + def create_watch_folder(self, *, user_id: str, path: str) -> dict: + return self._knowledge_service(user_id).create_watch_folder(path=path) + + def delete_watch_folder(self, *, user_id: str, folder_id: str) -> None: + self._knowledge_service(user_id).delete_watch_folder(folder_id=folder_id) + + async def import_watch_folder_path(self, *, user_id: str, path: str) -> dict: + return await self._knowledge_service(user_id).import_watch_folder_path(path=path) diff --git a/apps/api/app/services/memory_service.py b/apps/api/app/services/memory_service.py new file mode 100644 index 0000000..c529526 --- /dev/null +++ b/apps/api/app/services/memory_service.py @@ -0,0 +1,169 @@ +""" +Memory service — unified file-memory sync, runtime cleanup, and setup bootstrap. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import AppConfig, UserMemory + +logger = logging.getLogger(__name__) + +_SYNC_MTIME_KEY_PREFIX = "memory_doc_sync_mtime:" +_SYNC_EPSILON_SECONDS = 1e-6 + + +class MemoryService: + def __init__(self, db: AsyncSession, user_id: UUID): + self.db = db + self.user_id = user_id + + async def update_memory_doc(self, content_md: str) -> int: + from app.agents.memory.file_storage import get_memory_dir, write_memory_doc + + await asyncio.to_thread(write_memory_doc, content_md) + synced = await self._sync_memory_doc() + await self._set_synced_mtime(self._memory_doc_mtime(get_memory_dir() / "MEMORY.md")) + return synced + + async def sync_memory_doc_if_stale(self) -> int: + from app.agents.memory.file_storage import get_memory_dir + + path = get_memory_dir() / "MEMORY.md" + current_mtime = self._memory_doc_mtime(path) + if current_mtime is None: + return 0 + + last_synced = await self._get_synced_mtime() + if last_synced is not None and current_mtime <= last_synced + _SYNC_EPSILON_SECONDS: + return 0 + + synced = await self._sync_memory_doc() + await self._set_synced_mtime(current_mtime) + return synced + + async def cleanup_runtime_memories(self) -> int: + result = await self.db.execute( + select(UserMemory).where(UserMemory.user_id == self.user_id) + ) + memories = list(result.scalars().all()) + + authoritative_pairs = { + (str(m.key).strip(), str(m.value).strip()) + for m in memories + if m.source in {"conversation", "manual"} + } + + removed = 0 + for memory in memories: + key = str(memory.key or "").strip() + value = str(memory.value or "").strip() + + if key.startswith("diary_"): + await self.db.delete(memory) + removed += 1 + continue + + if memory.source == "file" and (key, value) in authoritative_pairs: + await self.db.delete(memory) + removed += 1 + + if removed: + await self.db.flush() + return removed + + async def bootstrap_setup_memories( + self, + *, + user_occupation: str = "", + user_preferences: str = "", + ) -> int: + from app.agents.memory import _upsert_memory + + count = 0 + + occupation = user_occupation.strip() + if occupation: + await _upsert_memory( + self.db, + self.user_id, + "user_occupation", + occupation, + confidence=0.95, + memory_type="fact", + ttl_days=None, + memory_kind="profile", + source="manual", + evidence="setup_init.user_occupation", + ) + count += 1 + + preferences = user_preferences.strip() + if preferences: + await _upsert_memory( + self.db, + self.user_id, + "user_preferences", + preferences, + confidence=0.95, + memory_type="preference", + ttl_days=None, + memory_kind="preference", + source="manual", + evidence="setup_init.user_preferences", + ) + count += 1 + + if count: + await self.db.flush() + return count + + async def _sync_memory_doc(self) -> int: + from app.agents.memory.file_storage import sync_memory_doc_to_db + + synced = await sync_memory_doc_to_db(self.user_id, self.db) + await self.cleanup_runtime_memories() + return synced + + async def _get_synced_mtime(self) -> float | None: + row = ( + await self.db.execute( + select(AppConfig).where(AppConfig.key == self._sync_mtime_key()) + ) + ).scalar_one_or_none() + if row is None or not row.value: + return None + try: + return float(row.value) + except (TypeError, ValueError): + return None + + async def _set_synced_mtime(self, mtime: float | None) -> None: + value = "" if mtime is None else f"{mtime:.6f}" + row = ( + await self.db.execute( + select(AppConfig).where(AppConfig.key == self._sync_mtime_key()) + ) + ).scalar_one_or_none() + if row is None: + self.db.add(AppConfig(key=self._sync_mtime_key(), value=value)) + return + row.value = value + + def _sync_mtime_key(self) -> str: + return f"{_SYNC_MTIME_KEY_PREFIX}{self.user_id}" + + @staticmethod + def _memory_doc_mtime(path: Path) -> float | None: + try: + return path.stat().st_mtime if path.exists() else None + except OSError as exc: + logger.warning("Failed to read MEMORY.md mtime: %s", exc) + return None diff --git a/apps/api/app/services/monitoring_service.py b/apps/api/app/services/monitoring_service.py index 8d05dcf..0785abe 100644 --- a/apps/api/app/services/monitoring_service.py +++ b/apps/api/app/services/monitoring_service.py @@ -17,14 +17,16 @@ from statistics import quantiles from typing import Any -from sqlalchemy import Select, func, select +from sqlalchemy import Select, and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import settings from app.database import AsyncSessionLocal +from app.exceptions import BadRequestError from app.models import ( MessageGeneration, + Notebook, ObservabilityLLMCall, ObservabilityRun, ObservabilitySpan, @@ -50,8 +52,35 @@ CHAT_STUCK_MINUTES = 5 RESEARCH_STUCK_MINUTES = 30 SCHEDULED_STUCK_MINUTES = 15 +SOURCE_INGEST_STUCK_MINUTES = 15 SNAPSHOT_HEAD_CHARS = 2000 SNAPSHOT_TAIL_CHARS = 1000 +OBSERVABILITY_RUN_TYPES = ( + "chat_generation", + "research_task", + "scheduled_task_run", + "source_ingest", +) +DEFAULT_TRACE_RUN_TYPES = OBSERVABILITY_RUN_TYPES +SUCCESS_STATUSES = {"succeeded"} +_OBSERVABILITY_STATUS_MAP = { + "success": "succeeded", + "done": "succeeded", + "completed": "succeeded", + "succeeded": "succeeded", + "error": "failed", + "failed": "failed", + "running": "running", + "cancelled": "cancelled", + "stuck": "stuck", +} +_WORKLOAD_STATUS_MAP = { + **_OBSERVABILITY_STATUS_MAP, + "pending": "running", + "processing": "running", + "queued": "running", + "indexed": "succeeded", +} @asynccontextmanager @@ -105,8 +134,47 @@ def compute_percentile(values: list[int], percentile: float) -> int | None: return round(bucket[index]) +def normalize_observability_status(status: str | None) -> str: + raw = (status or "running").strip().lower() + return _OBSERVABILITY_STATUS_MAP.get(raw, raw) + + +def normalize_workload_status(status: str | None) -> str: + raw = (status or "running").strip().lower() + return _WORKLOAD_STATUS_MAP.get(raw, raw) + + +def build_observability_status_filter(status: str | None) -> tuple[str, ...]: + normalized = normalize_observability_status(status) + if normalized == "succeeded": + return ("succeeded", "success", "done", "completed") + if normalized == "failed": + return ("failed", "error") + return (normalized,) + + def success_status(status: str) -> bool: - return status in {"success", "done", "completed"} + return normalize_observability_status(status) in SUCCESS_STATUSES + + +def infer_span_component(run_type: str | None, span_name: str | None = None) -> str: + if run_type == "source_ingest": + if span_name == "source_ingest.upload": + return "api" + return "ingest" + return "worker" + + +def encode_trace_cursor(started_at: datetime, run_id: uuid.UUID) -> str: + return f"{ensure_utc(started_at).isoformat()}|{run_id}" + + +def decode_trace_cursor(cursor: str) -> tuple[datetime, uuid.UUID]: + try: + raw_started_at, raw_id = cursor.split("|", 1) + return ensure_utc(datetime.fromisoformat(raw_started_at)), uuid.UUID(raw_id) + except (TypeError, ValueError) as exc: # pragma: no cover - guarded by API tests + raise BadRequestError("cursor 无效") from exc def build_worker_instance_id(component: str) -> str: @@ -253,11 +321,14 @@ async def create_observability_run( metadata: dict[str, Any] | None = None, started_at: datetime | None = None, ) -> ObservabilityRun: + if run_type not in OBSERVABILITY_RUN_TYPES: + raise ValueError(f"Unsupported observability run_type: {run_type}") + run = ObservabilityRun( trace_id=trace_id, run_type=run_type, name=name, - status=status, + status=normalize_observability_status(status), user_id=user_id, conversation_id=conversation_id, generation_id=generation_id, @@ -282,7 +353,7 @@ async def update_observability_run( finished_at: datetime | None = None, ) -> ObservabilityRun: if status is not None: - run.status = status + run.status = normalize_observability_status(status) if metadata: run.metadata_json = {**(run.metadata_json or {}), **metadata} if error_message is not None: @@ -360,7 +431,7 @@ async def record_completed_llm_call( call_type=call_type, provider=provider, model=model, - status=status, + status=normalize_observability_status(status), finish_reason=finish_reason, input_tokens=input_value, output_tokens=output_value, @@ -415,7 +486,7 @@ async def record_completed_tool_call( run_id=resolved_run.id, trace_id=resolved_run.trace_id, tool_name=tool_name, - status=status, + status=normalize_observability_status(status), cache_hit=cache_hit, result_count=result_count, followup_tool_hint=followup_tool_hint, @@ -455,6 +526,9 @@ async def traced_span( span_name: str, *, run: ObservabilityRun | None = None, + parent_span: ObservabilitySpan | None = None, + component: str | None = None, + span_kind: str | None = "phase", metadata: dict[str, Any] | None = None, ) -> AsyncIterator[ObservabilitySpan]: resolved_run = run @@ -463,13 +537,16 @@ async def traced_span( if run_id: resolved_run = await db.get(ObservabilityRun, uuid.UUID(run_id)) if resolved_run is None: - yield ObservabilitySpan(trace_id=get_trace_id() or "", span_name=span_name) + yield ObservabilitySpan(trace_id=get_trace_id() or "", span_name=span_name, component=component, span_kind=span_kind) return span = ObservabilitySpan( run_id=resolved_run.id, + parent_span_id=parent_span.id if parent_span else None, trace_id=resolved_run.trace_id, span_name=span_name, + component=component or infer_span_component(resolved_run.run_type, span_name), + span_kind=span_kind, status="running", metadata_json=metadata, started_at=utcnow(), @@ -480,14 +557,14 @@ async def traced_span( try: yield span except Exception as exc: - span.status = "error" + span.status = "failed" span.error_message = str(exc)[:2000] span.finished_at = utcnow() span.duration_ms = int((time.monotonic() - started) * 1000) await db.flush() raise else: - span.status = "success" + span.status = "succeeded" span.finished_at = utcnow() span.duration_ms = int((time.monotonic() - started) * 1000) await db.flush() @@ -498,7 +575,10 @@ async def record_instant_span( span_name: str, *, run: ObservabilityRun | None = None, - status: str = "success", + parent_span: ObservabilitySpan | None = None, + component: str | None = None, + span_kind: str | None = "phase", + status: str = "succeeded", metadata: dict[str, Any] | None = None, error_message: str | None = None, ) -> ObservabilitySpan | None: @@ -513,9 +593,12 @@ async def record_instant_span( now = utcnow() span = ObservabilitySpan( run_id=resolved_run.id, + parent_span_id=parent_span.id if parent_span else None, trace_id=resolved_run.trace_id, span_name=span_name, - status=status, + component=component or infer_span_component(resolved_run.run_type, span_name), + span_kind=span_kind, + status=normalize_observability_status(status), error_message=error_message[:2000] if error_message else None, metadata_json=metadata, started_at=now, @@ -532,7 +615,10 @@ async def record_completed_span( span_name: str, *, run: ObservabilityRun | None = None, - status: str = "success", + parent_span: ObservabilitySpan | None = None, + component: str | None = None, + span_kind: str | None = "phase", + status: str = "succeeded", metadata: dict[str, Any] | None = None, error_message: str | None = None, started_at: datetime | None = None, @@ -555,9 +641,12 @@ async def record_completed_span( span = ObservabilitySpan( run_id=resolved_run.id, + parent_span_id=parent_span.id if parent_span else None, trace_id=resolved_run.trace_id, span_name=span_name, - status=status, + component=component or infer_span_component(resolved_run.run_type, span_name), + span_kind=span_kind, + status=normalize_observability_status(status), error_message=error_message[:2000] if error_message else None, metadata_json=metadata, started_at=span_started_at, @@ -569,6 +658,35 @@ async def record_completed_span( return span +async def get_or_create_source_ingest_run( + db: AsyncSession, + *, + source_id: uuid.UUID, + notebook_id: uuid.UUID | None, + user_id: uuid.UUID | None = None, + trace_id: str | None = None, + run_id: uuid.UUID | None = None, + metadata: dict[str, Any] | None = None, +) -> ObservabilityRun: + if run_id is not None: + existing = await db.get(ObservabilityRun, run_id) + if existing is not None: + return existing + + resolved_trace_id = trace_id or get_trace_id() or uuid.uuid4().hex + return await create_observability_run( + db, + trace_id=resolved_trace_id, + run_type="source_ingest", + name="source.ingest", + status="running", + user_id=user_id, + task_id=source_id, + notebook_id=notebook_id, + metadata=metadata or {"source_id": str(source_id)}, + ) + + async def touch_worker_heartbeat( component: str, *, @@ -661,6 +779,7 @@ class WorkloadThreshold: "chat_generation": WorkloadThreshold("chat_generation", timedelta(minutes=CHAT_STUCK_MINUTES)), "research_task": WorkloadThreshold("research_task", timedelta(minutes=RESEARCH_STUCK_MINUTES)), "scheduled_task_run": WorkloadThreshold("scheduled_task_run", timedelta(minutes=SCHEDULED_STUCK_MINUTES)), + "source_ingest": WorkloadThreshold("source_ingest", timedelta(minutes=SOURCE_INGEST_STUCK_MINUTES)), } @@ -671,7 +790,7 @@ def __init__(self, db: AsyncSession): async def overview(self, *, window: str = "24h") -> dict[str, Any]: since = utcnow() - parse_window(window) business_runs = await self._load_runs(select(ObservabilityRun).where( - ObservabilityRun.run_type.in_(("chat_generation", "research_task")), + ObservabilityRun.run_type.in_(DEFAULT_TRACE_RUN_TYPES), ObservabilityRun.started_at >= since, )) chat_runs = await self._load_runs(select(ObservabilityRun).where( @@ -683,7 +802,7 @@ async def overview(self, *, window: str = "24h") -> dict[str, Any]: http_durations = [run.duration_ms for run in business_runs if run.duration_ms is not None] request_total = len(business_runs) - request_5xx = sum(1 for run in business_runs if run.status in {"error", "failed"}) + request_5xx = sum(1 for run in business_runs if normalize_observability_status(run.status) == "failed") chat_total = len(chat_runs) chat_success = sum(1 for run in chat_runs if success_status(run.status)) @@ -718,16 +837,26 @@ async def list_traces( run_type: str | None = None, status: str | None = None, cursor: str | None = None, + user_id: uuid.UUID | None = None, + conversation_id: uuid.UUID | None = None, + generation_id: uuid.UUID | None = None, + task_id: uuid.UUID | None = None, + task_run_id: uuid.UUID | None = None, + notebook_id: uuid.UUID | None = None, limit: int = 20, ) -> dict[str, Any]: since = utcnow() - parse_window(window) - base_stmt = select(ObservabilityRun).where(ObservabilityRun.started_at >= since) - if run_type: - base_stmt = base_stmt.where(ObservabilityRun.run_type == run_type) - else: - base_stmt = base_stmt.where(ObservabilityRun.run_type.in_(("chat_generation", "research_task"))) - if status: - base_stmt = base_stmt.where(ObservabilityRun.status == status) + base_stmt = self._apply_run_filters( + select(ObservabilityRun).where(ObservabilityRun.started_at >= since), + run_type=run_type, + status=status, + user_id=user_id, + conversation_id=conversation_id, + generation_id=generation_id, + task_id=task_id, + task_run_id=task_run_id, + notebook_id=notebook_id, + ) total = int((await self.db.execute( select(func.count()).select_from(base_stmt.subquery()) @@ -735,15 +864,22 @@ async def list_traces( stmt = base_stmt if cursor: - stmt = stmt.where(ObservabilityRun.trace_id < cursor) - stmt = stmt.order_by(ObservabilityRun.started_at.desc()).limit(limit + 1) + cursor_started_at, cursor_run_id = decode_trace_cursor(cursor) + stmt = stmt.where(or_( + ObservabilityRun.started_at < cursor_started_at, + and_( + ObservabilityRun.started_at == cursor_started_at, + ObservabilityRun.id < cursor_run_id, + ), + )) + stmt = stmt.order_by(ObservabilityRun.started_at.desc(), ObservabilityRun.id.desc()).limit(limit + 1) runs = await self._load_runs(stmt) has_more = len(runs) > limit items = runs[:limit] return { "items": [self._serialize_run(run) for run in items], "total": total, - "next_cursor": items[-1].trace_id if has_more and items else None, + "next_cursor": encode_trace_cursor(items[-1].started_at, items[-1].id) if has_more and items else None, } async def get_trace_detail(self, trace_id: str) -> dict[str, Any]: @@ -755,7 +891,7 @@ async def get_trace_detail(self, trace_id: str) -> dict[str, Any]: spans = list((await self.db.execute( select(ObservabilitySpan) .where(ObservabilitySpan.trace_id == trace_id) - .order_by(ObservabilitySpan.started_at.asc()) + .order_by(ObservabilitySpan.started_at.asc(), ObservabilitySpan.id.asc()) )).scalars().all()) llm_calls = list((await self.db.execute( select(ObservabilityLLMCall) @@ -768,8 +904,14 @@ async def get_trace_detail(self, trace_id: str) -> dict[str, Any]: .order_by(ObservabilityToolCall.started_at.asc()) )).scalars().all()) - total_duration_ms = sum(run.duration_ms or 0 for run in runs) - final_status = runs[-1].status if runs else "unknown" + if runs: + started_at = min(ensure_utc(run.started_at) for run in runs) + finished_at = max(ensure_utc(run.finished_at or run.started_at) for run in runs) + total_duration_ms = max(0, int((finished_at - started_at).total_seconds() * 1000)) + final_status = normalize_observability_status(runs[-1].status) + else: + total_duration_ms = 0 + final_status = "unknown" return { "trace_id": trace_id, "runs": [self._serialize_run(run) for run in runs], @@ -786,76 +928,143 @@ async def get_trace_detail(self, trace_id: str) -> dict[str, Any]: }, } - async def list_failures(self, *, window: str = "24h", kind: str | None = None) -> dict[str, Any]: + async def list_failures( + self, + *, + window: str = "24h", + kind: str | None = None, + user_id: uuid.UUID | None = None, + conversation_id: uuid.UUID | None = None, + generation_id: uuid.UUID | None = None, + task_id: uuid.UUID | None = None, + task_run_id: uuid.UUID | None = None, + notebook_id: uuid.UUID | None = None, + ) -> dict[str, Any]: since = utcnow() - parse_window(window) items: list[dict[str, Any]] = [] if kind in (None, "chat_generation"): - generations = list((await self.db.execute( - select(MessageGeneration).where( + stmt = select(MessageGeneration).where( MessageGeneration.started_at >= since, - MessageGeneration.status == "error", + MessageGeneration.status.in_(("error", "failed")), ) - )).scalars().all()) + if user_id is not None: + stmt = stmt.where(MessageGeneration.user_id == user_id) + if conversation_id is not None: + stmt = stmt.where(MessageGeneration.conversation_id == conversation_id) + if generation_id is not None: + stmt = stmt.where(MessageGeneration.id == generation_id) + generations = list((await self.db.execute(stmt)).scalars().all()) + trace_map = await self._load_trace_map( + run_type="chat_generation", + field_name="generation_id", + ids=[generation.id for generation in generations], + ) for generation in generations: + trace_id = trace_map.get(generation.id) items.append({ "kind": "chat_generation", "id": str(generation.id), - "status": generation.status, + "status": normalize_workload_status(generation.status), "message": generation.error_message, - "trace_id": await self._lookup_trace_id(generation_id=generation.id), + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "trace_not_found", "conversation_id": str(generation.conversation_id), "created_at": generation.started_at, }) if kind in (None, "research_task"): - tasks = list((await self.db.execute( - select(ResearchTask).where( + stmt = select(ResearchTask).where( ResearchTask.created_at >= since, - ResearchTask.status == "error", + ResearchTask.status.in_(("error", "failed")), ) - )).scalars().all()) + if user_id is not None: + stmt = stmt.where(ResearchTask.user_id == user_id) + if conversation_id is not None: + stmt = stmt.where(ResearchTask.conversation_id == conversation_id) + if task_id is not None: + stmt = stmt.where(ResearchTask.id == task_id) + if notebook_id is not None: + stmt = stmt.where(ResearchTask.notebook_id == str(notebook_id)) + tasks = list((await self.db.execute(stmt)).scalars().all()) + trace_map = await self._load_trace_map( + run_type="research_task", + field_name="task_id", + ids=[task.id for task in tasks], + ) for task in tasks: + trace_id = trace_map.get(task.id) items.append({ "kind": "research_task", "id": str(task.id), - "status": task.status, + "status": normalize_workload_status(task.status), "message": task.error_message, - "trace_id": await self._lookup_trace_id(task_id=task.id), + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "trace_not_found", "conversation_id": str(task.conversation_id) if task.conversation_id else None, "created_at": task.created_at, }) if kind in (None, "scheduled_task_run"): - runs = list((await self.db.execute( + stmt = ( select(ScheduledTaskRun, ScheduledTask.name) .join(ScheduledTask, ScheduledTaskRun.task_id == ScheduledTask.id) - .where(ScheduledTaskRun.started_at >= since, ScheduledTaskRun.status == "failed") - )).all()) + .where(ScheduledTaskRun.started_at >= since, ScheduledTaskRun.status.in_(("failed", "error"))) + ) + if user_id is not None: + stmt = stmt.where(ScheduledTask.user_id == user_id) + if task_id is not None: + stmt = stmt.where(ScheduledTaskRun.task_id == task_id) + if task_run_id is not None: + stmt = stmt.where(ScheduledTaskRun.id == task_run_id) + runs = list((await self.db.execute(stmt)).all()) + trace_map = await self._load_trace_map( + run_type="scheduled_task_run", + field_name="task_run_id", + ids=[run.id for run, _ in runs], + ) for run, task_name in runs: + trace_id = trace_map.get(run.id) items.append({ "kind": "scheduled_task_run", "id": str(run.id), - "status": run.status, + "status": normalize_workload_status(run.status), "message": run.error_message, "title": task_name, - "trace_id": await self._lookup_trace_id(task_id=run.task_id, task_run_id=run.id), + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "trace_not_found", "created_at": run.started_at, }) if kind in (None, "source_ingest"): - sources = list((await self.db.execute( - select(Source).where(Source.updated_at >= since, Source.status == "failed") - )).scalars().all()) + stmt = select(Source).where(Source.updated_at >= since, Source.status == "failed") + if user_id is not None: + stmt = stmt.join(Notebook, Source.notebook_id == Notebook.id).where(Notebook.user_id == user_id) + if notebook_id is not None: + stmt = stmt.where(Source.notebook_id == notebook_id) + if task_id is not None: + stmt = stmt.where(Source.id == task_id) + sources = list((await self.db.execute(stmt)).scalars().all()) + trace_map = await self._load_trace_map( + run_type="source_ingest", + field_name="task_id", + ids=[source.id for source in sources], + ) for source in sources: + trace_id = trace_map.get(source.id) items.append({ "kind": "source_ingest", "id": str(source.id), - "status": source.status, + "status": normalize_workload_status(source.status), "message": source.summary or source.title or "source ingest failed", "created_at": source.updated_at, "notebook_id": str(source.notebook_id), - "trace_id": None, + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "legacy_source_ingest_without_trace", }) items.sort(key=lambda item: item["created_at"], reverse=True) @@ -884,6 +1093,12 @@ async def list_workloads( *, kind: str | None = None, status: str | None = None, + user_id: uuid.UUID | None = None, + conversation_id: uuid.UUID | None = None, + generation_id: uuid.UUID | None = None, + task_id: uuid.UUID | None = None, + task_run_id: uuid.UUID | None = None, + notebook_id: uuid.UUID | None = None, offset: int = 0, limit: int = 20, ) -> dict[str, Any]: @@ -891,17 +1106,42 @@ async def list_workloads( items: list[dict[str, Any]] = [] if kind in (None, "chat_generation"): - chat_items = await self._build_chat_generation_items(status=status) + chat_items = await self._build_chat_generation_items( + status=status, + user_id=user_id, + conversation_id=conversation_id, + generation_id=generation_id, + ) summary.append(self._summarize_workload("chat_generation", chat_items)) items.extend(chat_items) if kind in (None, "research_task"): - research_items = await self._build_research_items(status=status) + research_items = await self._build_research_items( + status=status, + user_id=user_id, + conversation_id=conversation_id, + task_id=task_id, + notebook_id=notebook_id, + ) summary.append(self._summarize_workload("research_task", research_items)) items.extend(research_items) if kind in (None, "scheduled_task_run"): - scheduled_items = await self._build_scheduled_items(status=status) + scheduled_items = await self._build_scheduled_items( + status=status, + user_id=user_id, + task_id=task_id, + task_run_id=task_run_id, + ) summary.append(self._summarize_workload("scheduled_task_run", scheduled_items)) items.extend(scheduled_items) + if kind in (None, "source_ingest"): + ingest_items = await self._build_source_ingest_items( + status=status, + user_id=user_id, + task_id=task_id, + notebook_id=notebook_id, + ) + summary.append(self._summarize_workload("source_ingest", ingest_items)) + items.extend(ingest_items) items.sort(key=lambda item: item["started_at"], reverse=True) total = len(items) @@ -912,20 +1152,44 @@ async def list_workloads( item["finished_at"] = item["finished_at"].isoformat() return {"summary": summary, "items": page_items, "total": total} - async def _build_chat_generation_items(self, *, status: str | None) -> list[dict[str, Any]]: - result = await self.db.execute(select(MessageGeneration).order_by(MessageGeneration.started_at.desc()).limit(100)) + async def _build_chat_generation_items( + self, + *, + status: str | None, + user_id: uuid.UUID | None, + conversation_id: uuid.UUID | None, + generation_id: uuid.UUID | None, + ) -> list[dict[str, Any]]: + stmt = select(MessageGeneration).order_by(MessageGeneration.started_at.desc()).limit(100) + if user_id is not None: + stmt = stmt.where(MessageGeneration.user_id == user_id) + if conversation_id is not None: + stmt = stmt.where(MessageGeneration.conversation_id == conversation_id) + if generation_id is not None: + stmt = stmt.where(MessageGeneration.id == generation_id) + result = await self.db.execute(stmt) rows = list(result.scalars().all()) + trace_map = await self._load_trace_map( + run_type="chat_generation", + field_name="generation_id", + ids=[row.id for row in rows], + ) items: list[dict[str, Any]] = [] threshold = WORKLOAD_THRESHOLDS["chat_generation"].max_age + requested_status = normalize_workload_status(status) if status else None for row in rows: - stuck = row.status == "running" and utcnow() - ensure_utc(row.started_at) > threshold - normalized_status = "stuck" if stuck else row.status - if status and normalized_status != status: + base_status = normalize_workload_status(row.status) + stuck = base_status == "running" and utcnow() - ensure_utc(row.started_at) > threshold + normalized_status = "stuck" if stuck else base_status + if requested_status and normalized_status != requested_status: continue + trace_id = trace_map.get(row.id) items.append({ "kind": "chat_generation", "id": str(row.id), - "trace_id": await self._lookup_trace_id(generation_id=row.id), + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "trace_not_found", "status": normalized_status, "started_at": row.started_at, "finished_at": row.completed_at, @@ -937,20 +1201,47 @@ async def _build_chat_generation_items(self, *, status: str | None) -> list[dict }) return items - async def _build_research_items(self, *, status: str | None) -> list[dict[str, Any]]: - result = await self.db.execute(select(ResearchTask).order_by(ResearchTask.created_at.desc()).limit(100)) + async def _build_research_items( + self, + *, + status: str | None, + user_id: uuid.UUID | None, + conversation_id: uuid.UUID | None, + task_id: uuid.UUID | None, + notebook_id: uuid.UUID | None, + ) -> list[dict[str, Any]]: + stmt = select(ResearchTask).order_by(ResearchTask.created_at.desc()).limit(100) + if user_id is not None: + stmt = stmt.where(ResearchTask.user_id == user_id) + if conversation_id is not None: + stmt = stmt.where(ResearchTask.conversation_id == conversation_id) + if task_id is not None: + stmt = stmt.where(ResearchTask.id == task_id) + if notebook_id is not None: + stmt = stmt.where(ResearchTask.notebook_id == str(notebook_id)) + result = await self.db.execute(stmt) rows = list(result.scalars().all()) + trace_map = await self._load_trace_map( + run_type="research_task", + field_name="task_id", + ids=[row.id for row in rows], + ) items: list[dict[str, Any]] = [] threshold = WORKLOAD_THRESHOLDS["research_task"].max_age + requested_status = normalize_workload_status(status) if status else None for row in rows: - stuck = row.status == "running" and utcnow() - ensure_utc(row.created_at) > threshold - normalized_status = "stuck" if stuck else row.status - if status and normalized_status != status: + base_status = normalize_workload_status(row.status) + stuck = base_status == "running" and utcnow() - ensure_utc(row.created_at) > threshold + normalized_status = "stuck" if stuck else base_status + if requested_status and normalized_status != requested_status: continue + trace_id = trace_map.get(row.id) items.append({ "kind": "research_task", "id": str(row.id), - "trace_id": await self._lookup_trace_id(task_id=row.id), + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "trace_not_found", "status": normalized_status, "started_at": row.created_at, "finished_at": row.completed_at, @@ -962,26 +1253,50 @@ async def _build_research_items(self, *, status: str | None) -> list[dict[str, A }) return items - async def _build_scheduled_items(self, *, status: str | None) -> list[dict[str, Any]]: - result = await self.db.execute( + async def _build_scheduled_items( + self, + *, + status: str | None, + user_id: uuid.UUID | None, + task_id: uuid.UUID | None, + task_run_id: uuid.UUID | None, + ) -> list[dict[str, Any]]: + stmt = ( select(ScheduledTaskRun, ScheduledTask.name) .join(ScheduledTask, ScheduledTaskRun.task_id == ScheduledTask.id) .order_by(ScheduledTaskRun.started_at.desc()) .limit(100) ) + if user_id is not None: + stmt = stmt.where(ScheduledTask.user_id == user_id) + if task_id is not None: + stmt = stmt.where(ScheduledTaskRun.task_id == task_id) + if task_run_id is not None: + stmt = stmt.where(ScheduledTaskRun.id == task_run_id) + result = await self.db.execute(stmt) rows = list(result.all()) + trace_map = await self._load_trace_map( + run_type="scheduled_task_run", + field_name="task_run_id", + ids=[row.id for row, _ in rows], + ) items: list[dict[str, Any]] = [] threshold = WORKLOAD_THRESHOLDS["scheduled_task_run"].max_age + requested_status = normalize_workload_status(status) if status else None for row, task_name in rows: - stuck = row.status == "running" and utcnow() - ensure_utc(row.started_at) > threshold - normalized_status = "stuck" if stuck else row.status - if status and normalized_status != status: + base_status = normalize_workload_status(row.status) + stuck = base_status == "running" and utcnow() - ensure_utc(row.started_at) > threshold + normalized_status = "stuck" if stuck else base_status + if requested_status and normalized_status != requested_status: continue + trace_id = trace_map.get(row.id) items.append({ "kind": "scheduled_task_run", "id": str(row.id), "title": task_name, - "trace_id": await self._lookup_trace_id(task_id=row.task_id, task_run_id=row.id), + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "trace_not_found", "status": normalized_status, "started_at": row.started_at, "finished_at": row.finished_at, @@ -993,24 +1308,113 @@ async def _build_scheduled_items(self, *, status: str | None) -> list[dict[str, }) return items - async def _lookup_trace_id( + async def _build_source_ingest_items( self, *, - generation_id: uuid.UUID | None = None, - task_id: uuid.UUID | None = None, - task_run_id: uuid.UUID | None = None, - ) -> str | None: - stmt: Select[tuple[ObservabilityRun]] = select(ObservabilityRun) + status: str | None, + user_id: uuid.UUID | None, + task_id: uuid.UUID | None, + notebook_id: uuid.UUID | None, + ) -> list[dict[str, Any]]: + stmt = select(Source).order_by(Source.updated_at.desc()).limit(100) + if user_id is not None: + stmt = stmt.join(Notebook, Source.notebook_id == Notebook.id).where(Notebook.user_id == user_id) + if notebook_id is not None: + stmt = stmt.where(Source.notebook_id == notebook_id) + if task_id is not None: + stmt = stmt.where(Source.id == task_id) + result = await self.db.execute(stmt) + rows = list(result.scalars().all()) + trace_map = await self._load_trace_map( + run_type="source_ingest", + field_name="task_id", + ids=[row.id for row in rows], + ) + items: list[dict[str, Any]] = [] + threshold = WORKLOAD_THRESHOLDS["source_ingest"].max_age + requested_status = normalize_workload_status(status) if status else None + for row in rows: + base_status = normalize_workload_status(row.status) + stuck = base_status == "running" and utcnow() - ensure_utc(row.updated_at) > threshold + normalized_status = "stuck" if stuck else base_status + if requested_status and normalized_status != requested_status: + continue + trace_id = trace_map.get(row.id) + items.append({ + "kind": "source_ingest", + "id": str(row.id), + "trace_id": trace_id, + "trace_available": trace_id is not None, + "trace_missing_reason": None if trace_id else "legacy_source_ingest_without_trace", + "status": normalized_status, + "started_at": row.created_at, + "finished_at": row.updated_at if normalized_status in {"succeeded", "failed"} else None, + "conversation_id": None, + "task_id": str(row.id), + "task_run_id": None, + "message": row.summary or row.title, + "stuck": stuck, + }) + return items + + def _apply_run_filters( + self, + stmt: Select[tuple[ObservabilityRun]], + *, + run_type: str | None, + status: str | None, + user_id: uuid.UUID | None, + conversation_id: uuid.UUID | None, + generation_id: uuid.UUID | None, + task_id: uuid.UUID | None, + task_run_id: uuid.UUID | None, + notebook_id: uuid.UUID | None, + ) -> Select[tuple[ObservabilityRun]]: + if run_type: + stmt = stmt.where(ObservabilityRun.run_type == run_type) + else: + stmt = stmt.where(ObservabilityRun.run_type.in_(DEFAULT_TRACE_RUN_TYPES)) + if status: + stmt = stmt.where(ObservabilityRun.status.in_(build_observability_status_filter(status))) + if user_id is not None: + stmt = stmt.where(ObservabilityRun.user_id == user_id) + if conversation_id is not None: + stmt = stmt.where(ObservabilityRun.conversation_id == conversation_id) if generation_id is not None: stmt = stmt.where(ObservabilityRun.generation_id == generation_id) if task_id is not None: stmt = stmt.where(ObservabilityRun.task_id == task_id) if task_run_id is not None: stmt = stmt.where(ObservabilityRun.task_run_id == task_run_id) - stmt = stmt.order_by(ObservabilityRun.started_at.desc()).limit(1) - result = await self.db.execute(stmt) - run = result.scalar_one_or_none() - return run.trace_id if run else None + if notebook_id is not None: + stmt = stmt.where(ObservabilityRun.notebook_id == notebook_id) + return stmt + + async def _load_trace_map( + self, + *, + run_type: str, + field_name: str, + ids: list[uuid.UUID], + ) -> dict[uuid.UUID, str]: + if not ids: + return {} + + column = getattr(ObservabilityRun, field_name) + result = await self.db.execute( + select(column, ObservabilityRun.trace_id, ObservabilityRun.started_at, ObservabilityRun.id) + .where( + ObservabilityRun.run_type == run_type, + column.in_(ids), + ) + .order_by(column.asc(), ObservabilityRun.started_at.desc(), ObservabilityRun.id.desc()) + ) + mapping: dict[uuid.UUID, str] = {} + for row_id, trace_id, _, _ in result.all(): + if row_id is None or row_id in mapping: + continue + mapping[row_id] = trace_id + return mapping async def _load_runs(self, stmt: Select[tuple[ObservabilityRun]]) -> list[ObservabilityRun]: result = await self.db.execute(stmt) @@ -1022,7 +1426,7 @@ def _serialize_run(self, run: ObservabilityRun) -> dict[str, Any]: "trace_id": run.trace_id, "run_type": run.run_type, "name": run.name, - "status": run.status, + "status": normalize_observability_status(run.status), "user_id": str(run.user_id) if run.user_id else None, "conversation_id": str(run.conversation_id) if run.conversation_id else None, "generation_id": str(run.generation_id) if run.generation_id else None, @@ -1040,9 +1444,12 @@ def _serialize_span(self, span: ObservabilitySpan) -> dict[str, Any]: return { "id": str(span.id), "run_id": str(span.run_id), + "parent_span_id": str(span.parent_span_id) if span.parent_span_id else None, "trace_id": span.trace_id, "span_name": span.span_name, - "status": span.status, + "component": span.component or "worker", + "span_kind": span.span_kind or "phase", + "status": normalize_observability_status(span.status), "duration_ms": span.duration_ms, "error_message": span.error_message, "metadata": span.metadata_json or {}, @@ -1058,7 +1465,7 @@ def _serialize_llm_call(self, call: ObservabilityLLMCall) -> dict[str, Any]: "call_type": call.call_type, "provider": call.provider, "model": call.model, - "status": call.status, + "status": normalize_observability_status(call.status), "finish_reason": call.finish_reason, "input_tokens": call.input_tokens, "output_tokens": call.output_tokens, @@ -1080,7 +1487,7 @@ def _serialize_tool_call(self, call: ObservabilityToolCall) -> dict[str, Any]: "run_id": str(call.run_id), "trace_id": call.trace_id, "tool_name": call.tool_name, - "status": call.status, + "status": normalize_observability_status(call.status), "cache_hit": call.cache_hit, "result_count": call.result_count, "followup_tool_hint": call.followup_tool_hint, @@ -1098,7 +1505,7 @@ def _summarize_workload(self, kind: str, items: list[dict[str, Any]]) -> dict[st "kind": kind, "running_count": sum(1 for item in items if item["status"] == "running"), "stuck_count": sum(1 for item in items if item["status"] == "stuck"), - "failed_count": sum(1 for item in items if item["status"] in {"error", "failed"}), + "failed_count": sum(1 for item in items if item["status"] == "failed"), } diff --git a/apps/api/app/services/notebook_service.py b/apps/api/app/services/notebook_service.py new file mode 100644 index 0000000..b487e65 --- /dev/null +++ b/apps/api/app/services/notebook_service.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import delete as sql_delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.exceptions import NotFoundError +from app.models import Note, Notebook, Source +from app.services.public_home_service import refresh_public_home_draft + + +def _word_count_subquery(): + return ( + select(func.coalesce(func.sum(Note.word_count), 0)) + .where(Note.notebook_id == Notebook.id) + .correlate(Notebook) + .scalar_subquery() + .label("wc") + ) + + +def _source_count_subquery(): + return ( + select(func.count(Source.id)) + .where(Source.notebook_id == Notebook.id) + .correlate(Notebook) + .scalar_subquery() + .label("src_count") + ) + + +def _note_count_subquery(): + return ( + select(func.count(Note.id)) + .where(Note.notebook_id == Notebook.id) + .correlate(Notebook) + .scalar_subquery() + .label("note_count") + ) + + +def _build_out(nb: Notebook, src_count: int, note_count: int, word_count: int) -> Notebook: + nb.source_count = src_count + nb.note_count = note_count + nb.word_count = word_count + nb.summary_md = nb.summary.summary_md if nb.summary else None + return nb + + +def _notebook_with_counts_stmt(): + return select( + Notebook, + _source_count_subquery(), + _note_count_subquery(), + _word_count_subquery(), + ).options(selectinload(Notebook.summary)) + + +async def _get_notebook_with_counts( + db: AsyncSession, + *, + notebook_id: UUID, + user_id: UUID, +) -> Notebook: + result = await db.execute( + _notebook_with_counts_stmt().where( + Notebook.id == notebook_id, + Notebook.user_id == user_id, + ) + ) + row = result.one_or_none() + if row is None: + raise NotFoundError("笔记本不存在") + notebook, src_count, note_count, word_count = row + return _build_out(notebook, src_count, note_count, word_count) + + +async def get_owned_notebook(db: AsyncSession, notebook_id: UUID, user_id: UUID) -> Notebook: + return await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + + +async def list_user_notebooks(db: AsyncSession, user_id: UUID) -> list[Notebook]: + result = await db.execute( + _notebook_with_counts_stmt() + .where(Notebook.user_id == user_id, Notebook.is_global.is_(False)) + .order_by(Notebook.updated_at.desc()) + ) + return [ + _build_out(notebook, src_count, note_count, word_count) + for notebook, src_count, note_count, word_count in result.all() + ] + + +async def create_notebook(db: AsyncSession, user_id: UUID, payload: dict) -> Notebook: + notebook = Notebook(user_id=user_id, **payload) + db.add(notebook) + await db.flush() + await db.refresh(notebook) + await db.refresh(notebook, attribute_names=["summary"]) + return await _get_notebook_with_counts(db, notebook_id=notebook.id, user_id=user_id) + + +async def get_or_create_global_notebook(db: AsyncSession, user_id: UUID) -> Notebook: + result = await db.execute( + select(Notebook.id).where( + Notebook.user_id == user_id, + Notebook.is_global.is_(True), + ) + ) + notebook_id = result.scalar_one_or_none() + if notebook_id is None: + notebook = Notebook( + user_id=user_id, + title="全局知识库", + description="全局来源,不绑定具体笔记本。", + is_global=True, + is_system=False, + status="active", + ) + db.add(notebook) + await db.flush() + await db.refresh(notebook) + await db.refresh(notebook, attribute_names=["summary"]) + notebook_id = notebook.id + return await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + + +async def get_notebook_detail(db: AsyncSession, notebook_id: UUID, user_id: UUID) -> Notebook: + return await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + + +async def update_notebook( + db: AsyncSession, + notebook_id: UUID, + user_id: UUID, + changes: dict, +) -> Notebook: + notebook = await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + for field, value in changes.items(): + setattr(notebook, field, value) + await db.flush() + await db.refresh(notebook) + await db.refresh(notebook, attribute_names=["summary"]) + return await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + + +async def publish_notebook(db: AsyncSession, notebook_id: UUID, user_id: UUID) -> Notebook: + notebook = await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + notebook.is_public = True + notebook.published_at = datetime.now(timezone.utc) + await db.flush() + await refresh_public_home_draft(db, user_id) + await db.refresh(notebook) + await db.refresh(notebook, attribute_names=["summary"]) + return await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + + +async def unpublish_notebook(db: AsyncSession, notebook_id: UUID, user_id: UUID) -> Notebook: + notebook = await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + notebook.is_public = False + await db.flush() + await refresh_public_home_draft(db, user_id) + await db.refresh(notebook) + await db.refresh(notebook, attribute_names=["summary"]) + return await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + + +async def delete_notebook(db: AsyncSession, notebook_id: UUID, user_id: UUID) -> None: + await _get_notebook_with_counts(db, notebook_id=notebook_id, user_id=user_id) + await db.execute(sql_delete(Notebook).where(Notebook.id == notebook_id)) + await db.commit() diff --git a/apps/api/app/services/source_service.py b/apps/api/app/services/source_service.py index c161a20..b403d86 100644 --- a/apps/api/app/services/source_service.py +++ b/apps/api/app/services/source_service.py @@ -8,12 +8,14 @@ from __future__ import annotations -import asyncio +import hashlib import logging import os import uuid from collections.abc import AsyncIterator, Iterator from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from uuid import UUID @@ -21,8 +23,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.exceptions import ConflictError, NotFoundError +from app.database import enqueue_after_commit +from app.exceptions import BadRequestError, ConflictError, NotFoundError from app.models import Chunk, Notebook, Source +from app.services.monitoring_service import ( + create_observability_run, + record_completed_span, + record_instant_span, +) +from app.trace import generate_trace_id, get_trace_id +from app.utils.async_tasks import create_logged_task logger = logging.getLogger(__name__) @@ -30,6 +40,7 @@ ".pdf": "application/pdf", ".md": "text/markdown", ".txt": "text/plain", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", } @@ -137,7 +148,11 @@ def _normalize_web_url(url: str | None) -> str: )) def _dispatch_refresh_summary(self, notebook_id: UUID) -> None: - asyncio.create_task(self._refresh_summary_safe(notebook_id)) + create_logged_task( + self._refresh_summary_safe(notebook_id), + logger=logger, + description=f"refresh notebook summary after source change {notebook_id}", + ) async def _refresh_summary_safe(self, notebook_id: UUID) -> None: from app.agents.memory import refresh_notebook_summary @@ -155,6 +170,99 @@ async def _stream_file(self, file_path: str) -> AsyncIterator[bytes]: while chunk := await f.read(65536): yield chunk + async def _create_source_ingest_run( + self, + source: Source, + *, + origin: str, + ): + trace_id = get_trace_id() or generate_trace_id() + run = await create_observability_run( + self.db, + trace_id=trace_id, + run_type="source_ingest", + name="source.ingest", + status="running", + user_id=self.user_id, + task_id=source.id, + notebook_id=source.notebook_id, + metadata={ + "origin": origin, + "source_id": str(source.id), + "source_title": source.title, + "source_type": source.type, + "source_url": source.url, + }, + ) + return trace_id, run + + def _enqueue_ingestion( + self, + source_id: UUID, + *, + trace_id: str, + run_id: UUID, + job_kind: str = "import", + job_label: str | None = None, + chunk_size: int | None = None, + chunk_overlap: int | None = None, + splitter_type: str | None = None, + separators: list[str] | None = None, + min_chunk_size: int | None = None, + ) -> None: + def _dispatch() -> None: + if settings.is_desktop_runtime: + from app.services.desktop_runtime_service import desktop_job_manager + + desktop_job_manager.enqueue_source_ingest( + user_id=str(self.user_id), + source_id=str(source_id), + trace_id=trace_id, + run_id=str(run_id), + kind=job_kind, + label=job_label or f"索引任务 {source_id}", + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + splitter_type=splitter_type, + separators=separators, + min_chunk_size=min_chunk_size, + ) + return + + from app.workers.tasks import ingest_source + + logger.info( + "Dispatching ingest_source for source %s (chunk_size=%s, chunk_overlap=%s, splitter_type=%s)", + source_id, + chunk_size, + chunk_overlap, + splitter_type, + ) + if ( + chunk_size is None + and chunk_overlap is None + and splitter_type is None + and separators is None + and min_chunk_size is None + ): + ingest_source.delay(str(source_id), trace_id=trace_id, run_id=str(run_id)) + return + + ingest_source.apply_async( + args=[str(source_id)], + kwargs={ + "trace_id": trace_id, + "run_id": str(run_id), + "chunk_size": chunk_size, + "chunk_overlap": chunk_overlap, + "splitter_type": splitter_type, + "separators": separators, + "min_chunk_size": min_chunk_size, + }, + ) + + enqueue_after_commit(self.db, _dispatch) + # ── Upload / Import ──────────────────────────────────────────────────────── async def upload_source( @@ -163,16 +271,13 @@ async def upload_source( await self._assert_notebook_owner(notebook_id) ext = os.path.splitext(filename or "")[1].lower() - type_map = {".pdf": "pdf", ".md": "md", ".txt": "md"} + type_map = {".pdf": "pdf", ".md": "md", ".txt": "md", ".docx": "doc"} source_type = type_map.get(ext, "md") file_id = str(uuid.uuid4()) storage_key = f"notebooks/{notebook_id}/{file_id}{ext}" content_type = self._guess_content_type(filename or "") - from app.providers.storage import storage as get_storage - await get_storage().upload(storage_key, content, content_type) - source = Source( notebook_id=notebook_id, title=filename, @@ -184,9 +289,36 @@ async def upload_source( self.db.add(source) await self.db.flush() await self.db.refresh(source) + trace_id, run = await self._create_source_ingest_run(source, origin="upload") - from app.workers.tasks import ingest_source - ingest_source.delay(str(source.id)) + from app.providers.storage import storage as get_storage + + upload_started_at = datetime.now(UTC) + await get_storage().upload(storage_key, content, content_type) + await record_completed_span( + self.db, + "source_ingest.upload", + run=run, + component="api", + span_kind="phase", + status="succeeded", + metadata={ + "content_type": content_type, + "filename": filename, + "byte_length": len(content), + }, + started_at=upload_started_at, + finished_at=datetime.now(UTC), + ) + + self._enqueue_ingestion( + source.id, + trace_id=trace_id, + run_id=run.id, + job_kind="import", + job_label=f"索引资料:{filename or '未命名文件'}", + ) + await self.db.commit() return source @@ -205,9 +337,25 @@ async def import_source_url( self.db.add(source) await self.db.flush() await self.db.refresh(source) + trace_id, run = await self._create_source_ingest_run(source, origin="url_import") + await record_instant_span( + self.db, + "source_ingest.upload", + run=run, + component="api", + span_kind="phase", + status="succeeded", + metadata={"mode": "url", "url": url, "skipped": True}, + ) - from app.workers.tasks import ingest_source - ingest_source.delay(str(source.id)) + self._enqueue_ingestion( + source.id, + trace_id=trace_id, + run_id=run.id, + job_kind="import", + job_label=f"索引网页:{title or url}", + ) + await self.db.commit() return source @@ -259,15 +407,32 @@ async def import_web_sources( self.db.add(source) await self.db.flush() await self.db.refresh(source) + trace_id, run = await self._create_source_ingest_run(source, origin="web_batch_import") + await record_instant_span( + self.db, + "source_ingest.upload", + run=run, + component="api", + span_kind="phase", + status="succeeded", + metadata={"mode": "url", "url": normalized_url, "skipped": True}, + ) - from app.workers.tasks import ingest_source - - ingest_source.delay(str(source.id)) + self._enqueue_ingestion( + source.id, + trace_id=trace_id, + run_id=run.id, + job_kind="import", + job_label=f"索引网页:{source.title or normalized_url}", + ) existing_urls.add(normalized_url) created_count += 1 source_ids.append(source.id) + if created_count > 0: + await self.db.commit() + return WebImportResult( notebook_id=target_notebook_id, created_count=created_count, @@ -376,17 +541,17 @@ async def rechunk_source( source.status = "pending" await self.db.flush() - from app.workers.tasks import ingest_source - ingest_source.apply_async( - args=[str(source.id)], - kwargs={ - "chunk_size": size, - "chunk_overlap": overlap, - "splitter_type": splitter_type, - "separators": separators, - "min_chunk_size": min_chunk_size, - }, + self._enqueue_ingestion( + source.id, + job_kind="rechunk", + job_label=f"重建索引:{source.title or source.id}", + chunk_size=size, + chunk_overlap=overlap, + splitter_type=splitter_type, + separators=separators, + min_chunk_size=min_chunk_size, ) + await self.db.commit() return size, overlap # ── Download ────────────────────────────────────────────────────────────── @@ -485,8 +650,12 @@ async def upload_global_source(self, filename: str | None, content: bytes) -> So await self.db.flush() await self.db.refresh(source) - from app.workers.tasks import ingest_source - ingest_source.delay(str(source.id)) + self._enqueue_ingestion( + source.id, + job_kind="import", + job_label=f"索引资料:{filename or '未命名文件'}", + ) + await self.db.commit() return source @@ -504,7 +673,71 @@ async def import_global_source_url(self, url: str, title: str | None) -> Source: await self.db.flush() await self.db.refresh(source) - from app.workers.tasks import ingest_source - ingest_source.delay(str(source.id)) + self._enqueue_ingestion( + source.id, + job_kind="import", + job_label=f"索引网页:{title or url}", + ) + await self.db.commit() + + return source + + async def import_global_source_path( + self, + path: str, + *, + sha256: str | None = None, + ) -> Source: + file_path = Path(path).expanduser() + if not file_path.exists(): + raise NotFoundError("文件不存在") + if not file_path.is_file(): + raise BadRequestError("暂不支持导入目录") + + try: + content = file_path.read_bytes() + except OSError as exc: + raise BadRequestError("无法读取所选文件") from exc + + content_sha256 = sha256 + if settings.is_desktop_runtime and not content_sha256: + content_sha256 = hashlib.sha256(content).hexdigest() + + if settings.is_desktop_runtime: + from app.services.desktop_runtime_service import desktop_state_store + + inspection = desktop_state_store.inspect_local_file( + user_id=str(self.user_id), + path=str(file_path.resolve()), + sha256=content_sha256, + ) + existing_source_id = inspection.get("source_id") + if inspection["state"] in {"unchanged", "duplicate"} and existing_source_id: + try: + existing_source = await self._get_owned_source(UUID(str(existing_source_id))) + except (NotFoundError, ValueError): + existing_source = None + if existing_source is not None: + desktop_state_store.record_import( + user_id=str(self.user_id), + path=str(file_path.resolve()), + source_id=str(existing_source.id), + title=existing_source.title or file_path.name, + sha256=content_sha256, + ) + return existing_source + + source = await self.upload_global_source(file_path.name, content) + + if settings.is_desktop_runtime: + from app.services.desktop_runtime_service import desktop_state_store + + desktop_state_store.record_import( + user_id=str(self.user_id), + path=str(file_path.resolve()), + source_id=str(source.id), + title=source.title or file_path.name, + sha256=content_sha256, + ) return source diff --git a/apps/api/app/services/suggestion_service.py b/apps/api/app/services/suggestion_service.py index 1d054e8..4b867f8 100644 --- a/apps/api/app/services/suggestion_service.py +++ b/apps/api/app/services/suggestion_service.py @@ -45,12 +45,25 @@ def __init__( async def get_user_suggestions(self, user_id: UUID) -> list[str]: """ - Read precomputed suggestions from cache. - This path never triggers LLM generation. + Read cached suggestions, and opportunistically warm the cache on miss. + Falls back to static prompts only when no context is available or + generation fails. """ payload = await self._read_cached_payload(str(user_id)) if payload and payload.get("suggestions"): return payload["suggestions"] + + try: + refreshed = await self.refresh_user_suggestions(user_id) + except Exception: + logger.exception("Failed to warm suggestions cache for user=%s", user_id) + refreshed = False + + if refreshed: + payload = await self._read_cached_payload(str(user_id)) + if payload and payload.get("suggestions"): + return payload["suggestions"] + return list(FALLBACK_SUGGESTIONS) async def refresh_active_user_suggestions(self) -> dict[str, int]: diff --git a/apps/api/app/services/upload_service.py b/apps/api/app/services/upload_service.py new file mode 100644 index 0000000..53c82cf --- /dev/null +++ b/apps/api/app/services/upload_service.py @@ -0,0 +1,65 @@ +""" +Upload service — temp attachment storage helpers. +""" + +from __future__ import annotations + +import mimetypes +import uuid + +from fastapi import HTTPException, UploadFile +from fastapi.responses import Response + +from app.providers.storage import storage + +MAX_UPLOAD_SIZE = 20 * 1024 * 1024 # 20 MB +IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") + + +class UploadService: + async def upload_temp_file( + self, + file: UploadFile, + user_id: str, + ) -> dict[str, str | int]: + content = await file.read() + if len(content) > MAX_UPLOAD_SIZE: + raise HTTPException(status_code=413, detail="文件大小不能超过 20 MB") + + ext = "" + if file.filename: + ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + + file_id = str(uuid.uuid4()) + content_type = ( + file.content_type + or mimetypes.guess_type(file.filename or "")[0] + or "application/octet-stream" + ) + storage_key = f"temp/{user_id}/{file_id}{ext}" + + await storage().upload(storage_key, content, content_type) + + return { + "id": file_id, + "storage_key": storage_key, + "filename": file.filename or f"{file_id}{ext}", + "content_type": content_type, + "size": len(content), + } + + async def get_temp_file(self, file_id: str, user_id: str) -> Response: + store = storage() + for ext in ("", ".pdf", ".txt", ".md", ".doc", ".docx", *IMAGE_EXTS): + key = f"temp/{user_id}/{file_id}{ext}" + try: + if not await store.exists(key): + continue + data = await store.download(key) + content_type = ( + mimetypes.guess_type(f"f{ext}")[0] or "application/octet-stream" + ) + return Response(content=data, media_type=content_type) + except FileNotFoundError: + continue + raise HTTPException(status_code=404, detail="File not found") diff --git a/apps/api/app/skills/builtin/update_memory_doc.py b/apps/api/app/skills/builtin/update_memory_doc.py index 449871a..11b3b6f 100644 --- a/apps/api/app/skills/builtin/update_memory_doc.py +++ b/apps/api/app/skills/builtin/update_memory_doc.py @@ -48,23 +48,16 @@ def _build_schema(self, config: dict) -> dict: } async def execute(self, args: dict, ctx: "ToolContext") -> str: - import asyncio - from app.agents.memory.file_storage import write_memory_doc, sync_memory_doc_to_db + from app.services.memory_service import MemoryService content_md: str = args.get("content_md", "").strip() if not content_md: return "记忆内容不能为空。" - await asyncio.to_thread(write_memory_doc, content_md) - - # Always sync MEMORY.md back to DB so the content is available via - # build_memory_context() in future conversations (regardless of memory_mode). try: - synced = await sync_memory_doc_to_db(ctx.user_id, ctx.db, force=True) - if synced: - await ctx.db.flush() + await MemoryService(ctx.db, ctx.user_id).update_memory_doc(content_md) except Exception: - pass # File write succeeded; DB sync failure is non-fatal + return "记忆文档写入失败,请稍后重试。" return "已成功更新全局记忆文档。" diff --git a/apps/api/app/utils/async_tasks.py b/apps/api/app/utils/async_tasks.py new file mode 100644 index 0000000..634df94 --- /dev/null +++ b/apps/api/app/utils/async_tasks.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Coroutine +from typing import Any + + +def create_logged_task( + coro: Coroutine[Any, Any, Any], + *, + logger: logging.Logger, + description: str, +) -> asyncio.Task[Any]: + task = asyncio.create_task(coro, name=description) + + def _on_done(done_task: asyncio.Task[Any]) -> None: + if done_task.cancelled(): + logger.debug("%s cancelled", description) + return + exc = done_task.exception() + if exc is not None: + logger.debug("%s failed: %s", description, exc) + + task.add_done_callback(_on_done) + return task diff --git a/apps/api/app/workers/_helpers.py b/apps/api/app/workers/_helpers.py index 90fda98..6af6614 100644 --- a/apps/api/app/workers/_helpers.py +++ b/apps/api/app/workers/_helpers.py @@ -29,7 +29,7 @@ async def _load(): engine = _cae(settings.database_url, poolclass=NullPool) factory = _asm(bind=engine, class_=AsyncSession, expire_on_commit=False) async with factory() as db: - from app.domains.setup.router import load_settings_from_db + from app.services.config_service import load_settings_from_db await load_settings_from_db(db) await engine.dispose() diff --git a/apps/api/app/workers/celery_app.py b/apps/api/app/workers/celery_app.py index 2da4ec0..abb5c07 100644 --- a/apps/api/app/workers/celery_app.py +++ b/apps/api/app/workers/celery_app.py @@ -6,6 +6,7 @@ from celery import Celery from celery.schedules import crontab +from kombu import Queue from app.config import settings @@ -23,30 +24,60 @@ enable_utc=True, task_track_started=True, worker_hijack_root_logger=False, + task_default_queue="celery", + task_default_exchange="celery", + task_default_routing_key="celery", + task_create_missing_queues=True, + task_queues=( + Queue("celery"), + Queue("ingestion"), + Queue("scheduled"), + Queue("maintenance"), + ), + task_routes={ + "ingest_source": {"queue": "ingestion"}, + "extract_knowledge_graph": {"queue": "maintenance"}, + "rebuild_knowledge_graph": {"queue": "maintenance"}, + "index_note": {"queue": "ingestion"}, + "postprocess_indexed_source": {"queue": "maintenance"}, + "execute_scheduled_task": {"queue": "scheduled"}, + "check_scheduled_tasks": {"queue": "maintenance"}, + "expire_stuck_sources": {"queue": "maintenance"}, + "precompute_ai_suggestions": {"queue": "maintenance"}, + "decay_all_user_memories": {"queue": "maintenance"}, + "synthesize_all_user_portraits": {"queue": "maintenance"}, + "cleanup_observability": {"queue": "maintenance"}, + }, beat_schedule={ "decay-stale-memories-daily": { "task": "decay_all_user_memories", "schedule": 86400.0, + "options": {"queue": "maintenance"}, }, "check-scheduled-tasks": { "task": "check_scheduled_tasks", "schedule": 60.0, + "options": {"queue": "maintenance"}, }, "weekly-portrait-synthesis": { "task": "synthesize_all_user_portraits", "schedule": crontab(hour=3, day_of_week=1), # Every Monday at 03:00 UTC + "options": {"queue": "maintenance"}, }, "expire-stuck-sources": { "task": "expire_stuck_sources", "schedule": 600.0, # every 10 minutes + "options": {"queue": "maintenance"}, }, "precompute-ai-suggestions": { "task": "precompute_ai_suggestions", "schedule": 600.0, # every 10 minutes + "options": {"queue": "maintenance"}, }, "cleanup-observability-daily": { "task": "cleanup_observability", "schedule": crontab(hour=4, minute=0), + "options": {"queue": "maintenance"}, }, }, ) @@ -86,7 +117,7 @@ def _runner() -> None: def _on_setup_logger(**_kwargs): """Override Celery's default logging with our unified formatter.""" from app.logging_config import setup_logging - setup_logging(debug=settings.debug) + setup_logging(debug=settings.debug, logs_dir=settings.logs_dir) @worker_process_init.connect @@ -94,7 +125,7 @@ def _on_worker_init(**_kwargs): """Runs in each forked worker process: load DB config (API keys etc.).""" from app.logging_config import setup_logging from app.workers._helpers import _load_db_settings_sync - setup_logging(debug=settings.debug) + setup_logging(debug=settings.debug, logs_dir=settings.logs_dir) _load_db_settings_sync() _start_heartbeat_thread("worker") @@ -104,6 +135,6 @@ def _on_beat_init(**_kwargs): from app.logging_config import setup_logging from app.workers._helpers import _load_db_settings_sync - setup_logging(debug=settings.debug) + setup_logging(debug=settings.debug, logs_dir=settings.logs_dir) _load_db_settings_sync() _start_heartbeat_thread("beat") diff --git a/apps/api/app/workers/tasks/__init__.py b/apps/api/app/workers/tasks/__init__.py index 66d70fa..3d6e742 100644 --- a/apps/api/app/workers/tasks/__init__.py +++ b/apps/api/app/workers/tasks/__init__.py @@ -16,6 +16,7 @@ extract_knowledge_graph, expire_stuck_sources, ingest_source, + postprocess_indexed_source, rebuild_knowledge_graph_task, ) from app.workers.tasks.memory import ( # noqa: F401 diff --git a/apps/api/app/workers/tasks/ingestion.py b/apps/api/app/workers/tasks/ingestion.py index 1094743..a06e0b1 100644 --- a/apps/api/app/workers/tasks/ingestion.py +++ b/apps/api/app/workers/tasks/ingestion.py @@ -13,6 +13,46 @@ from app.workers._helpers import _run_async, _task_db +def _mark_source_failed(source, reason: str) -> None: + """Mark a source as failed without relying on fields that Source does not expose.""" + source.status = "failed" + if reason == "indexing_timeout": + message = "索引超时,请稍后重试或调整切分参数后重新导入。" + elif reason == "storage_missing": + message = "索引失败:原始文件不存在,请重新上传后重试。" + else: + message = f"索引失败:{reason}" + + if not getattr(source, "summary", None): + source.summary = message + + +async def _expire_stuck_sources_impl(db) -> int: + import logging + from datetime import datetime, timedelta, timezone + from sqlalchemy import select + from app.models import Source as SourceModel + + logger = logging.getLogger(__name__) + cutoff = datetime.now(timezone.utc) - timedelta(minutes=15) + + result = await db.execute( + select(SourceModel).where( + SourceModel.status.in_(["processing", "pending"]), + SourceModel.updated_at < cutoff, + ) + ) + stuck = result.scalars().all() + if stuck: + for src in stuck: + _mark_source_failed(src, "indexing_timeout") + await db.commit() + logger.warning( + "expire_stuck_sources: marked %d sources as failed (timeout)", len(stuck) + ) + return len(stuck) + + @celery_app.task( name="ingest_source", bind=True, @@ -23,17 +63,49 @@ def ingest_source( self, source_id: str, + trace_id: str | None = None, + run_id: str | None = None, chunk_size: int = 512, chunk_overlap: int = 64, - splitter_type: str = "auto", + splitter_type: str = "recursive", separators: list | None = None, min_chunk_size: int = 50, ): async def _run(): from app.agents.rag.ingestion import ingest + from app.models import Source as SourceModel + from app.services.monitoring_service import ( + bind_trace_and_run, + finish_observability_run, + get_or_create_source_ingest_run, + reset_trace_and_run, + ) + from celery.exceptions import MaxRetriesExceededError + from sqlalchemy import select + from uuid import UUID async with _task_db() as db: + trace_token = None + run_token = None + observability_run = None + source = None try: + source_result = await db.execute( + select(SourceModel).where(SourceModel.id == UUID(source_id)) + ) + source = source_result.scalar_one_or_none() + if source is None: + raise ValueError(f"Source {source_id} not found") + + observability_run = await get_or_create_source_ingest_run( + db, + source_id=source.id, + notebook_id=source.notebook_id, + trace_id=trace_id, + run_id=UUID(run_id) if run_id else None, + metadata={"origin": "celery_worker"}, + ) + trace_token, run_token = bind_trace_and_run(observability_run.trace_id, observability_run.id) await ingest( source_id, db, @@ -43,65 +115,213 @@ async def _run(): separators=separators, min_chunk_size=min_chunk_size, ) + if source.status == "indexed": + await finish_observability_run( + db, + observability_run, + status="succeeded", + metadata={"source_status": source.status}, + ) + else: + await finish_observability_run( + db, + observability_run, + status="failed", + metadata={"source_status": source.status}, + error_message=source.summary or "source ingest failed", + ) await db.commit() + if source.status == "indexed": + postprocess_indexed_source.delay(source_id) except Exception as exc: await db.rollback() + try: + if isinstance(exc, FileNotFoundError): + async with _task_db() as db2: + res = await db2.execute( + select(SourceModel).where(SourceModel.id == UUID(source_id)) + ) + src = res.scalar_one_or_none() + failed_run = None + if src: + failed_run = await get_or_create_source_ingest_run( + db2, + source_id=src.id, + notebook_id=src.notebook_id, + trace_id=trace_id, + run_id=UUID(run_id) if run_id else None, + metadata={"origin": "celery_worker"}, + ) + _mark_source_failed(src, "storage_missing") + if failed_run is not None: + await finish_observability_run( + db2, + failed_run, + status="failed", + metadata={"source_status": "failed"}, + error_message="storage_missing", + ) + await db2.commit() + return + except Exception: + pass # On SoftTimeLimitExceeded, mark the source as failed immediately # instead of retrying (retrying a timed-out task rarely helps) try: from billiard.exceptions import SoftTimeLimitExceeded as _STLE if isinstance(exc, _STLE): - from uuid import UUID - from sqlalchemy import select - from app.models import Source as SourceModel async with _task_db() as db2: res = await db2.execute( select(SourceModel).where(SourceModel.id == UUID(source_id)) ) src = res.scalar_one_or_none() + failed_run = None if src: - src.status = "failed" - src.metadata_ = {**(src.metadata_ or {}), "error": "indexing_timeout"} + failed_run = await get_or_create_source_ingest_run( + db2, + source_id=src.id, + notebook_id=src.notebook_id, + trace_id=trace_id, + run_id=UUID(run_id) if run_id else None, + metadata={"origin": "celery_worker"}, + ) + _mark_source_failed(src, "indexing_timeout") + if failed_run is not None: + await finish_observability_run( + db2, + failed_run, + status="failed", + metadata={"source_status": "failed"}, + error_message="indexing_timeout", + ) await db2.commit() return except Exception: pass - raise self.retry(exc=exc, countdown=30) + try: + raise self.retry(exc=exc, countdown=30) + except MaxRetriesExceededError: + async with _task_db() as db2: + res = await db2.execute( + select(SourceModel).where(SourceModel.id == UUID(source_id)) + ) + src = res.scalar_one_or_none() + failed_run = None + if src: + failed_run = await get_or_create_source_ingest_run( + db2, + source_id=src.id, + notebook_id=src.notebook_id, + trace_id=trace_id, + run_id=UUID(run_id) if run_id else None, + metadata={"origin": "celery_worker"}, + ) + _mark_source_failed(src, str(exc)) + if failed_run is not None: + await finish_observability_run( + db2, + failed_run, + status="failed", + metadata={"source_status": "failed"}, + error_message=str(exc), + ) + await db2.commit() + raise + finally: + if trace_token is not None and run_token is not None: + reset_trace_and_run(trace_token, run_token) _run_async(_run()) -@celery_app.task(name="expire_stuck_sources") -def expire_stuck_sources(): - """ - Celery Beat task (every 10 min): find sources stuck in 'processing' or 'pending' - for more than 15 minutes and mark them as 'failed'. - """ +@celery_app.task(name="postprocess_indexed_source", bind=True, max_retries=1) +def postprocess_indexed_source(self, source_id: str): + """Run slow, non-critical post-processing after chunks are safely committed.""" + async def _run(): import logging - from datetime import datetime, timedelta, timezone + from uuid import UUID + from sqlalchemy import select - from app.models import Source as SourceModel + + from app.agents.memory import refresh_notebook_summary + from app.agents.rag.ingestion import _generate_summary + from app.models import Notebook as NbModel + from app.models import ProactiveInsight, Source as SourceModel logger = logging.getLogger(__name__) - cutoff = datetime.now(timezone.utc) - timedelta(minutes=15) async with _task_db() as db: - result = await db.execute( - select(SourceModel).where( - SourceModel.status.in_(["processing", "pending"]), - SourceModel.updated_at < cutoff, + try: + result = await db.execute( + select(SourceModel).where(SourceModel.id == UUID(source_id)) ) - ) - stuck = result.scalars().all() - if stuck: - for src in stuck: - src.status = "failed" - src.metadata_ = {**(src.metadata_ or {}), "error": "indexing_timeout"} + source = result.scalar_one_or_none() + if source is None or source.status != "indexed" or not source.raw_text: + return + + try: + summary = await _generate_summary(source.raw_text[:3000]) + if summary: + source.summary = summary + await db.flush() + except Exception as exc: + logger.warning("Source summary generation failed for %s: %s", source_id, exc) + + try: + await refresh_notebook_summary(source.notebook_id, db) + except Exception as exc: + logger.warning( + "Notebook summary refresh failed for source %s: %s", + source_id, + exc, + ) + + try: + nb_result = await db.execute( + select(NbModel.user_id).where(NbModel.id == source.notebook_id) + ) + user_id = nb_result.scalar_one_or_none() + if user_id: + insight = ProactiveInsight( + user_id=user_id, + notebook_id=source.notebook_id, + insight_type="source_indexed", + title=f"「{source.title or '新资料'}」已完成索引", + content=(source.summary or "")[:200] or None, + ) + db.add(insight) + except Exception as exc: + logger.warning( + "Proactive insight generation failed for source %s: %s", + source_id, + exc, + ) + await db.commit() - logger.warning( - "expire_stuck_sources: marked %d sources as failed (timeout)", len(stuck) - ) + except Exception as exc: + await db.rollback() + raise self.retry(exc=exc, countdown=60) + + try: + from app.workers.tasks import extract_knowledge_graph + + extract_knowledge_graph.delay(source_id) + except Exception: + pass + + _run_async(_run()) + + +@celery_app.task(name="expire_stuck_sources") +def expire_stuck_sources(): + """ + Celery Beat task (every 10 min): find sources stuck in 'processing' or 'pending' + for more than 15 minutes and mark them as 'failed'. + """ + async def _run(): + async with _task_db() as db: + await _expire_stuck_sources_impl(db) _run_async(_run()) diff --git a/apps/api/requirements-dev.txt b/apps/api/requirements-dev.txt index 04eb719..20a9427 100644 --- a/apps/api/requirements-dev.txt +++ b/apps/api/requirements-dev.txt @@ -4,3 +4,4 @@ pytest-asyncio>=0.24 httpx>=0.28 pytest-cov>=5.0 aiosqlite>=0.20 +pyinstaller>=6.0 diff --git a/apps/api/scripts/build_desktop_sidecar.py b/apps/api/scripts/build_desktop_sidecar.py new file mode 100644 index 0000000..de3b5ef --- /dev/null +++ b/apps/api/scripts/build_desktop_sidecar.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import argparse +import importlib.util +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +API_DIR = ROOT / "apps" / "api" +BINARIES_DIR = ROOT / "apps" / "desktop" / "src-tauri" / "binaries" +SIDECAR_BASENAME = "lyranote-api-desktop" +RUNTIME_DIR_NAME = f"{SIDECAR_BASENAME}-runtime" +MIN_PYTHON_VERSION = (3, 12) + + +def detect_target_triple() -> str: + machine = platform.machine().lower() + system = platform.system().lower() + if system == "darwin": + if machine in {"arm64", "aarch64"}: + return "aarch64-apple-darwin" + if machine in {"x86_64", "amd64"}: + return "x86_64-apple-darwin" + raise RuntimeError( + f"Unsupported host for automatic target detection: system={system}, machine={machine}" + ) + + +def is_supported_python(version_info: tuple[int, ...] = sys.version_info[:3]) -> bool: + return version_info >= MIN_PYTHON_VERSION + + +def sidecar_paths(output_dir: Path, target_triple: str) -> tuple[Path, Path]: + return ( + output_dir / f"{SIDECAR_BASENAME}-{target_triple}", + output_dir / RUNTIME_DIR_NAME, + ) + + +def wrapper_script_content() -> str: + return f"""#!/bin/sh +set -eu + +SCRIPT_DIR="$(CDPATH= cd "$(dirname "$0")" && pwd)" +BUNDLED_RUNTIME="$SCRIPT_DIR/../Resources/{RUNTIME_DIR_NAME}/{SIDECAR_BASENAME}" +LOCAL_RUNTIME="$SCRIPT_DIR/{RUNTIME_DIR_NAME}/{SIDECAR_BASENAME}" + +if [ -x "$BUNDLED_RUNTIME" ]; then + exec "$BUNDLED_RUNTIME" "$@" +fi + +if [ -x "$LOCAL_RUNTIME" ]; then + exec "$LOCAL_RUNTIME" "$@" +fi + +echo "LyraNote desktop sidecar runtime is missing. Run: cd apps/api && python3 scripts/build_desktop_sidecar.py" >&2 +echo "Checked: $BUNDLED_RUNTIME" >&2 +echo "Checked: $LOCAL_RUNTIME" >&2 +exit 127 +""" + + +def write_wrapper(output_path: Path) -> None: + output_path.write_text(wrapper_script_content(), encoding="utf-8") + output_path.chmod(0o755) + + +def pyinstaller_command(entrypoint: Path, dist_dir: Path, work_dir: Path, spec_dir: Path) -> list[str]: + return [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--onedir", + "--contents-directory", + "_internal", + "--name", + SIDECAR_BASENAME, + "--distpath", + str(dist_dir), + "--workpath", + str(work_dir), + "--specpath", + str(spec_dir), + "--paths", + str(API_DIR), + "--collect-submodules", + "app", + "--collect-data", + "app", + "--hidden-import", + "aiosqlite", + str(entrypoint), + ] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build the bundled LyraNote desktop sidecar binary.") + parser.add_argument("--target-triple", default="", help="Override the output target triple.") + parser.add_argument( + "--output-dir", + default=str(BINARIES_DIR), + help="Directory where the bundled binary should be written.", + ) + args = parser.parse_args() + + target_triple = args.target_triple or detect_target_triple() + output_dir = Path(args.output_dir).expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + wrapper_path, runtime_dir = sidecar_paths(output_dir, target_triple) + + if not is_supported_python(): + required = ".".join(str(part) for part in MIN_PYTHON_VERSION) + current = ".".join(str(part) for part in sys.version_info[:3]) + print( + f"Desktop sidecar must be built with Python {required}+; current interpreter is {current}.", + file=sys.stderr, + ) + return 1 + + if importlib.util.find_spec("PyInstaller") is None: + print( + "PyInstaller is not installed. Install it with `python3 -m pip install pyinstaller` first.", + file=sys.stderr, + ) + return 1 + + entrypoint = API_DIR / "app" / "desktop_main.py" + build_dir = API_DIR / "tmp" / "pyinstaller-desktop" + dist_dir = build_dir / "dist" + work_dir = build_dir / "build" + spec_dir = build_dir / "spec" + build_dir.mkdir(parents=True, exist_ok=True) + + command = pyinstaller_command(entrypoint, dist_dir, work_dir, spec_dir) + + env = os.environ.copy() + env.setdefault("PYTHONPATH", str(API_DIR)) + env.setdefault("PYINSTALLER_CONFIG_DIR", str(build_dir / "cache")) + subprocess.run(command, cwd=API_DIR, env=env, check=True) + + built_runtime_dir = dist_dir / SIDECAR_BASENAME + built_binary = built_runtime_dir / SIDECAR_BASENAME + if not built_binary.exists(): + raise FileNotFoundError(f"PyInstaller finished but binary was not found at {built_binary}") + + if runtime_dir.exists(): + shutil.rmtree(runtime_dir) + shutil.copytree(built_runtime_dir, runtime_dir) + (runtime_dir / SIDECAR_BASENAME).chmod(0o755) + write_wrapper(wrapper_path) + print(wrapper_path) + print(runtime_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/3acf8dfe-1723-4896-a9e6-23a547d5737a.md b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/3acf8dfe-1723-4896-a9e6-23a547d5737a.md new file mode 100644 index 0000000..901fdac --- /dev/null +++ b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/3acf8dfe-1723-4896-a9e6-23a547d5737a.md @@ -0,0 +1,3 @@ +# test + +this is a tiny ingestion regression check \ No newline at end of file diff --git a/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/41b24de4-9d20-48d8-bc3c-9fe8fdeb4934.md b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/41b24de4-9d20-48d8-bc3c-9fe8fdeb4934.md new file mode 100644 index 0000000..1822a81 --- /dev/null +++ b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/41b24de4-9d20-48d8-bc3c-9fe8fdeb4934.md @@ -0,0 +1,7 @@ +# Codex Ingestion Smoke Test + +This source is used to verify the pending -> processing -> indexed pipeline. + +- queue routing +- after commit dispatch +- worker consumption diff --git a/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/72708d3a-a42e-4a96-9fe2-28081462ed31.md b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/72708d3a-a42e-4a96-9fe2-28081462ed31.md new file mode 100644 index 0000000..1822a81 --- /dev/null +++ b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/72708d3a-a42e-4a96-9fe2-28081462ed31.md @@ -0,0 +1,7 @@ +# Codex Ingestion Smoke Test + +This source is used to verify the pending -> processing -> indexed pipeline. + +- queue routing +- after commit dispatch +- worker consumption diff --git a/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/85239377-83ad-478b-b819-f36d894bb9e3.pdf b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/85239377-83ad-478b-b819-f36d894bb9e3.pdf new file mode 100644 index 0000000..e1571d1 Binary files /dev/null and b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/85239377-83ad-478b-b819-f36d894bb9e3.pdf differ diff --git a/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/a9b726fd-1b6f-4444-a297-0dbdb73fdd3c.md b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/a9b726fd-1b6f-4444-a297-0dbdb73fdd3c.md new file mode 100644 index 0000000..1822a81 --- /dev/null +++ b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/a9b726fd-1b6f-4444-a297-0dbdb73fdd3c.md @@ -0,0 +1,7 @@ +# Codex Ingestion Smoke Test + +This source is used to verify the pending -> processing -> indexed pipeline. + +- queue routing +- after commit dispatch +- worker consumption diff --git a/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/a9e24316-a846-4e3c-8b20-52234859609e.md b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/a9e24316-a846-4e3c-8b20-52234859609e.md new file mode 100644 index 0000000..d3b8c27 --- /dev/null +++ b/apps/api/storage/notebooks/9a2371fa-8a99-4436-b330-f35bf43c893c/a9e24316-a846-4e3c-8b20-52234859609e.md @@ -0,0 +1,240 @@ +# LyraNote 本科毕设全局设计说明(论文版) + +> 文档用途:用于本科毕业论文中的“研究背景、系统设计、技术特点、关键难点与创新点”章节初稿。 +> +> 版本日期:2026-03-26 +> +> 适用对象:论文作者、答辩评审、项目协作者 + +--- + +## 摘要 + +LyraNote 是一个面向个人研究与知识管理场景的 AI 驱动系统,目标是将传统“知识存储工具”升级为“可持续协作的研究伙伴”。系统围绕“私有知识优先”的设计原则,整合了 RAG 检索增强、多 Agent 编排、长期记忆、主动感知、深度研究与可视化生成界面(GenUI)等能力,形成从信息采集、理解、生成到反馈演化的闭环。 + +与常见 AI 笔记产品相比,LyraNote 不仅提供被动问答,还重点探索 AI 的主动性与持续性:在用户未显式提问时,系统仍可基于上下文状态进行低干扰主动辅助;在长周期任务中,系统支持定时调度与自动投递;在多轮交互中,系统通过多层记忆与画像机制持续提升个性化质量。该系统具有较强工程实现价值与研究讨论价值,适合作为“智能知识系统 + Agent 工程化”方向的本科毕设课题。 + +--- + +## 1. 研究背景与意义 + +### 1.1 研究背景 + +当前主流知识管理工具(笔记类应用、文档类应用)普遍存在两类不足: + +1. 知识层面:多来源信息可存储但难以深度理解,跨文档关联弱,检索结果与用户真实任务脱节。 +2. AI 层面:大多停留在“你问我答”的被动模式,缺乏持续状态感知、长期个性化与任务自动执行能力。 + +与此同时,通用大模型虽然具备强生成能力,但在真实研究流程中仍面临幻觉、上下文窗口限制、长任务中断、可解释性不足、部署复杂等问题。基于此,LyraNote 以“面向研究场景的 AI 原生第二大脑”为目标,尝试把 LLM 能力转化为可持续、可控、可落地的系统能力。 + +### 1.2 研究意义 + +1. 应用意义:提高个人研究与学习效率,减少“搜集-整理-输出”的重复劳动。 +2. 工程意义:构建可扩展的 AI 系统架构,验证多 Agent、记忆系统、事件驱动链路在真实产品中的可行性。 +3. 学术意义:为“从被动对话到主动协作”的 AI Agent 产品化路径提供可复用方法与实验设计。 + +--- + +## 2. 研究目标与问题定义 + +### 2.1 总体目标 + +构建一个支持“知识摄取—语义检索—深度研究—结构化表达—长期演化”的 AI 研究笔记系统,实现 AI 从工具属性向伙伴属性的演进。 + +### 2.2 核心研究问题 + +1. 如何让 AI 回答建立在用户私有知识之上,而不是泛化互联网知识? +2. 如何在复杂任务中提升质量与稳定性,避免单 Agent 架构的能力拥挤? +3. 如何让 AI 在不打扰用户的前提下实现主动感知与主动辅助? +4. 如何在工程上实现可扩展、可维护、可自托管的系统架构? + +--- + +## 3. 系统总体架构设计 + +### 3.1 分层总体架构 + +LyraNote 采用“前端交互层 + 后端服务层 + AI 能力层 + 基础设施层”的分层设计: + +1. 前端交互层(Next.js + Tiptap + SSE) +用于笔记编辑、对话交互、研究进度可视化、结构化内容展示。 +2. 后端服务层(FastAPI) +负责路由编排、鉴权、业务规则与 API 暴露。 +3. AI 能力层(Agents + Skills + Memory) +负责检索、写作、深度研究、工具调用、记忆更新与主动洞察。 +4. 基础设施层(PostgreSQL/pgvector + Redis + Celery + S3 兼容存储) +负责结构化数据、向量索引、异步任务、对象存储与调度。 + +### 3.2 关键数据闭环 + +系统形成“源→知识→对话→笔记→再入库”的闭环: + +1. 用户导入 PDF/网页/Markdown。 +2. 后端异步完成解析、分块、向量化并入库(RAG 索引)。 +3. 对话阶段基于检索证据生成回答并附引用。 +4. 用户将结果沉淀为笔记、摘要或报告(Artifact)。 +5. 笔记可再次作为来源进入知识库(Note-as-Source),形成持续演化。 + +### 3.3 AI 核心架构 + +#### 3.3.1 多 Agent 协作架构(System A) + +采用“主 Agent(Orchestrator)+ 专家 Agent(RAG/Research/Writing/Memory/Web)”的两层决策: + +1. 主 Agent 负责任务级路由(把问题交给最合适的专家)。 +2. 专家 Agent 负责步骤级执行(检索、搜索、写作、记忆更新等)。 + +该设计缓解了单 ReAct 循环能力拥挤、复杂任务步骤不足的问题。 + +#### 3.3.2 Soul 持续思维与表达机制(System B) + +通过活动感知、后台思维循环和表达门控机制,让 AI 在用户静默期仍可进行低频高价值思考,并在合适时机以可忽略的方式推送洞察。 + +#### 3.3.3 用户画像与记忆体系(System C) + +系统从“碎片记忆”升级到“多层记忆 + 用户画像”: + +1. 记忆层存储偏好、事实、场景与反思信息。 +2. 画像层按周期进行综合,形成对用户研究方向、能力水平、表达偏好的立体表征。 +3. 画像结果回注到路由决策与回答策略,实现长期个性化。 + +--- + +## 4. 核心特点(面向论文阐述) + +### 4.1 私有知识优先的 RAG 管道 + +系统在 Query 改写、多路检索、混合排序、重排与去重环节进行优化,重点解决多轮指代、召回不足和内容冗余问题。根据优化设计预期: + +1. 召回率提升约 25%~35%。 +2. 精确率提升约 20%。 +3. 首次响应延迟下降约 30%。 + +### 4.2 深度研究(Deep Research)能力 + +区别于单轮问答,深度研究支持“规划→检索→递归扩展→综合成文”的多阶段流程,支持 Quick/Deep 两种预算模式,并通过 SSE 反馈研究过程。为提升鲁棒性,研究任务与连接流解耦,支持刷新恢复与断点续传。 + +### 4.3 GenUI 结构化表达能力 + +通过统一 `genui` 协议将模型输出映射为可交互组件(图表、表格、时间轴、矩阵、看板等),提升研究结果可读性与可操作性,使 AI 输出从“纯文本”升级为“可视化知识单元”。 + +### 4.4 主动感知与自动化执行 + +系统提出“微交互渗透→上下文智能→自主行动”的三层主动架构: + +1. 在索引完成、编辑停顿、页面切换等时机提供低打扰提示。 +2. 通过定时任务实现持续任务自动执行(采集、生成、投递)。 +3. 将 AI 从一次性交互扩展为长期运行助手。 + +### 4.5 可自托管与工程可扩展性 + +1. 认证从外部 Clerk 迁移到本地单用户方案(bcrypt + JWT + 初始化向导),降低部署门槛。 +2. 存储层引入 `StorageProvider` 抽象,统一本地与 S3 兼容后端,支持横向扩展与多环境部署。 +3. 后端引入分层重构思路(Router/Service/Modules/Infrastructure),提升可维护性与测试性。 + +--- + +## 5. 关键难点与解决思路 + +### 难点 1:检索质量与多轮语义理解 + +问题:用户问题口语化、上下文省略、语义跨度大,导致检索召回不稳定。 +解决:引入对话感知 Query 改写、多 Query 并行、向量+全文混合检索、MMR 去重与 Cross-Encoder 重排。 + +### 难点 2:深度研究任务长链路稳定性 + +问题:若研究流程与 SSE 连接强耦合,用户刷新页面会导致任务中断。 +解决:任务后台化(独立运行)+ 事件缓冲 + 状态查询,支持断点续传与结果恢复。 + +### 难点 3:上下文窗口与成本控制 + +问题:深度研究在多轮检索后易超出上下文窗口,且联网搜索有调用成本。 +解决:学习结果摘要压缩、结果数量上限、检索阈值触发 Web Search、关键词去重与模式化预算(Quick/Deep)。 + +### 难点 4:主动性与用户体验平衡 + +问题:主动提示过多会造成干扰。 +解决:采用“适时、适量、可忽略”原则,限制触发频率与展示密度,允许用户关闭或忽略。 + +### 难点 5:系统复杂度持续上升 + +问题:功能扩展导致路由臃肿、职责混乱、可维护性下降。 +解决:推进 Service 分层、模块化 Agents、统一 Provider 接口、Schema 规范化,降低耦合度。 + +### 难点 6:自托管部署与多环境兼容 + +问题:文件存储、认证依赖、异步任务在不同部署环境中易出兼容性问题。 +解决:认证本地化、存储抽象化、任务队列标准化(Celery + Redis)、配置集中化管理。 + +--- + +## 6. 论文可主张的创新点 + +1. 提出并实现“多 Agent 协作 + Soul 持续感知 + 用户画像演化”的三系统耦合架构。 +2. 将 AI 交互从“被动问答”扩展到“主动感知与任务执行”的产品形态。 +3. 在研究报告场景中实现 Deep Research 的工程化闭环(多阶段推理、可恢复任务、可复用产物)。 +4. 提出并落地面向生成式应用的统一 GenUI 协议,增强 LLM 输出可视化表达能力。 +5. 结合记忆与技能系统,实现“能力可插拔 + 个性可演化”的长期 Agent 机制。 + +--- + +## 7. 论文实验与评估建议(可直接写入实验章节) + +### 7.1 建议评估维度 + +1. 检索效果:Recall@K、Precision@K、nDCG。 +2. 回答质量:事实一致性、引用覆盖率、结构完整度。 +3. 任务能力:复杂任务完成率、平均完成时长、失败重试率。 +4. 主动交互:建议点击率、忽略率、用户主观满意度。 +5. 个性化效果:开启/关闭记忆后的质量差异、长期 `quality_score` 变化。 +6. 工程性能:首 token 延迟、平均响应时延、任务吞吐量、资源消耗。 + +### 7.2 建议对比实验 + +1. 单 Agent vs 多 Agent(复杂任务完成率对比)。 +2. 无记忆 vs V1 记忆 vs V2 记忆(个性化质量对比)。 +3. 被动模式 vs 主动模式(交互效率与满意度对比)。 +4. 纯文本输出 vs GenUI 输出(信息理解效率与可用性对比)。 + +--- + +## 8. 局限性与后续工作 + +1. 深度研究仍依赖外部搜索服务,成本与稳定性受第三方接口影响。 +2. 多 Agent 路由效果对 Prompt 与结构化输出稳定性敏感。 +3. 主动机制目前主要面向单用户场景,多用户协作机制有待拓展。 +4. 评估体系仍需更大规模用户实验来验证泛化性。 + +后续可重点推进: + +1. 更细粒度的路由评估与自动纠偏机制。 +2. 更强的多模态研究能力(图像/表格/代码执行)。 +3. 研究任务模板化与可复现实验流水线。 +4. 面向团队协作的多租户与权限机制。 + +--- + +## 9. 文档依据(可作为论文内部参考来源) + +1. `architecture.md`(系统总架构、AI 流水线) +2. `lyra-soul-system.md`(多 Agent + Soul + 画像) +3. `memory-system-v2.md`(五层记忆体系) +4. `skills-system.md`(可插拔技能架构) +5. `deep-research.md`(多阶段研究流程与任务解耦) +6. `rag-optimization.md`(检索优化链路) +7. `genui-integration.md`(GenUI 协议) +8. `proactive-ai-system.md`(主动感知体系) +9. `scheduled-tasks.md`(定时任务自动化) +10. `backend-architecture-refactor.md`(后端分层重构) +11. `storage-system.md`(存储抽象) +12. `single-user-auth.md`(单用户自托管认证) + +--- + +## 附:论文写作使用建议 + +1. 可将第 1-3 节作为“绪论 + 总体设计”。 +2. 可将第 4-5 节拆为“关键技术实现”。 +3. 可将第 6-7 节作为“创新点与实验设计”。 +4. 可将第 8 节作为“总结与展望”。 + diff --git a/apps/api/tests/integration/test_memory_router.py b/apps/api/tests/integration/test_memory_router.py new file mode 100644 index 0000000..90bc680 --- /dev/null +++ b/apps/api/tests/integration/test_memory_router.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from sqlalchemy import select + +from app.models import UserMemory + + +@pytest.mark.asyncio +async def test_patch_memory_doc_syncs_structured_memories( + client, + auth_headers, + test_user, + db_session, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + monkeypatch.setattr("app.config.settings.memory_dir", str(tmp_path)) + + async def _fake_embed_query(_text: str) -> list[float]: + return [0.0] * 1536 + + monkeypatch.setattr("app.providers.embedding.embed_query", _fake_embed_query) + + user, _password = test_user + payload = { + "content_md": ( + "# 我的 AI 记忆\n\n" + "## About Me\n" + "I build agents.\n\n" + "## Constraints\n" + "Keep answers short.\n" + ) + } + + response = await client.patch("/api/v1/memory/doc", json=payload, headers=auth_headers) + + assert response.status_code == 204 + + rows = ( + await db_session.execute( + select(UserMemory).where(UserMemory.user_id == user.id).order_by(UserMemory.key.asc()) + ) + ).scalars().all() + assert [row.key for row in rows] == ["file_about_me", "file_constraints"] + + get_response = await client.get("/api/v1/memory/doc", headers=auth_headers) + assert get_response.status_code == 200 + assert get_response.json()["data"]["content_md"] == payload["content_md"] diff --git a/apps/api/tests/unit/agents/test_ghost_text.py b/apps/api/tests/unit/agents/test_ghost_text.py new file mode 100644 index 0000000..4b393ff --- /dev/null +++ b/apps/api/tests/unit/agents/test_ghost_text.py @@ -0,0 +1,35 @@ +import pytest + +from app.agents.writing.ghost_text import rewrite_selection + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("action", "expected_fragment"), + [ + ("polish", "润色"), + ("proofread", "校对"), + ("reformat", "重新整理"), + ("shorten", "精简"), + ("expand", "扩写"), + ], +) +async def test_rewrite_selection_uses_expected_prompt(monkeypatch: pytest.MonkeyPatch, action: str, expected_fragment: str): + captured: dict[str, object] = {} + + async def fake_chat(messages: list[dict[str, str]], temperature: float): + captured["messages"] = messages + captured["temperature"] = temperature + return "rewritten" + + monkeypatch.setattr("app.providers.llm.chat", fake_chat) + + result = await rewrite_selection("原始文本", action, "笔记背景") + + assert result == "rewritten" + messages = captured["messages"] + assert isinstance(messages, list) + assert expected_fragment in messages[0]["content"] + assert "原始文本" in messages[1]["content"] + assert "笔记背景" in messages[1]["content"] + assert captured["temperature"] == 0.6 diff --git a/apps/api/tests/unit/test_activity_router.py b/apps/api/tests/unit/test_activity_router.py new file mode 100644 index 0000000..2459fb1 --- /dev/null +++ b/apps/api/tests/unit/test_activity_router.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json + +import pytest + + +class _FakeRedis: + def __init__(self) -> None: + self.calls: list[tuple[str, int, str]] = [] + + async def __aenter__(self) -> "_FakeRedis": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + async def setex(self, key: str, ttl: int, payload: str) -> None: + self.calls.append((key, ttl, payload)) + + +@pytest.mark.asyncio +async def test_activity_heartbeat_accepts_surface_control_fields( + client, + auth_headers, + monkeypatch, + test_user, +) -> None: + user, _ = test_user + fake_redis = _FakeRedis() + monkeypatch.setattr("app.domains.activity.router.aioredis.from_url", lambda *args, **kwargs: fake_redis) + + response = await client.post( + "/api/v1/activity/heartbeat", + headers=auth_headers, + json={ + "action": "reading", + "notebook_id": "nb-1", + "copilot_open": True, + "is_mobile": True, + "typing_recently": True, + "last_interaction_ms": 123456, + "timestamp_ms": 999, + }, + ) + + assert response.status_code == 200 + assert len(fake_redis.calls) == 1 + + key, ttl, payload = fake_redis.calls[0] + body = json.loads(payload) + + assert key == f"activity:{user.id}" + assert ttl == 120 + assert body["copilot_open"] is True + assert body["is_mobile"] is True + assert body["typing_recently"] is True + assert body["last_interaction_ms"] == 123456 diff --git a/apps/api/tests/unit/test_agent_engine_injection.py b/apps/api/tests/unit/test_agent_engine_injection.py index 9e76e87..94660f6 100644 --- a/apps/api/tests/unit/test_agent_engine_injection.py +++ b/apps/api/tests/unit/test_agent_engine_injection.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import asyncio import uuid from collections.abc import AsyncGenerator from unittest.mock import AsyncMock @@ -272,6 +273,36 @@ async def test_default_llm_backend_satisfies_protocol() -> None: assert isinstance(backend, LLMBackend) +@pytest.mark.asyncio +async def test_exec_call_tools_propagates_cancellation(monkeypatch) -> None: + monkeypatch.setattr( + "app.agents.core.engine.record_completed_tool_call", + AsyncMock(), + ) + monkeypatch.setattr( + "app.agents.core.engine.traced_span", + lambda *a, **kw: _NullAsyncContext(), + ) + + async def cancelled_execute_tool(*_args, **_kwargs): + raise asyncio.CancelledError() + + monkeypatch.setattr( + "app.agents.core.engine.execute_tool", + cancelled_execute_tool, + ) + + engine, state = _make_engine(FakeLLMBackend(), has_tools=True) + from app.agents.core.instructions import CallToolsInstruction + + instruction = CallToolsInstruction( + tool_calls=[{"id": "tc1", "name": "search_notebook_knowledge", "arguments": {"query": "cancel"}}] + ) + + with pytest.raises(asyncio.CancelledError): + _ = [e async for e in engine._exec_call_tools(instruction, state)] + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/apps/api/tests/unit/test_agent_engine_observability.py b/apps/api/tests/unit/test_agent_engine_observability.py index 0e3d3a0..90149de 100644 --- a/apps/api/tests/unit/test_agent_engine_observability.py +++ b/apps/api/tests/unit/test_agent_engine_observability.py @@ -84,4 +84,4 @@ async def fake_graph_augmented_context(*args, **kwargs): "chat.rag.retrieve", "chat.graph.retrieve", ] - assert all(span.status == "success" for span in spans) + assert all(span.status == "succeeded" for span in spans) diff --git a/apps/api/tests/unit/test_agent_soul.py b/apps/api/tests/unit/test_agent_soul.py new file mode 100644 index 0000000..8289746 --- /dev/null +++ b/apps/api/tests/unit/test_agent_soul.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from app.agents.soul.soul import AgentSoul + + +class _FakeRedis: + def __init__(self) -> None: + self.published: list[tuple[str, str]] = [] + self.cooldowns: list[tuple[str, int, str]] = [] + + async def exists(self, key: str) -> int: + return 0 + + async def publish(self, channel: str, payload: str) -> None: + self.published.append((channel, payload)) + + async def setex(self, key: str, ttl: int, value: str) -> None: + self.cooldowns.append((key, ttl, value)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "activity", + [ + {"typing_recently": True}, + {"copilot_open": True}, + {"is_mobile": True}, + ], +) +async def test_agent_soul_keeps_thought_internal_in_high_interrupt_contexts(activity, monkeypatch) -> None: + fake_redis = _FakeRedis() + stored_visibilities: list[str] = [] + + async def fake_chat(*args, **kwargs) -> str: + return '{"should_surface": true, "content": "test thought", "reasoning": "ok"}' + + async def fake_store_thought(**kwargs) -> None: + stored_visibilities.append(kwargs["visibility"]) + + monkeypatch.setattr("app.providers.llm.chat", fake_chat) + monkeypatch.setattr("app.providers.llm.get_utility_model", lambda: "test-model") + monkeypatch.setattr("app.agents.soul.soul._store_thought", fake_store_thought) + + soul = AgentSoul() + await soul._think(user_id="00000000-0000-0000-0000-000000000001", activity=activity, redis=fake_redis) + + assert fake_redis.published == [] + assert fake_redis.cooldowns == [] + assert stored_visibilities == ["internal"] + + +@pytest.mark.asyncio +async def test_agent_soul_uses_thirty_minute_surface_cooldown(monkeypatch) -> None: + fake_redis = _FakeRedis() + + async def fake_chat(*args, **kwargs) -> str: + return '{"should_surface": true, "content": "test thought", "reasoning": "ok"}' + + async def fake_store_thought(**kwargs) -> None: + return None + + monkeypatch.setattr("app.providers.llm.chat", fake_chat) + monkeypatch.setattr("app.providers.llm.get_utility_model", lambda: "test-model") + monkeypatch.setattr("app.agents.soul.soul._store_thought", fake_store_thought) + + soul = AgentSoul() + await soul._think( + user_id="00000000-0000-0000-0000-000000000001", + activity={"typing_recently": False, "copilot_open": False, "is_mobile": False}, + redis=fake_redis, + ) + + assert len(fake_redis.published) == 1 + assert fake_redis.cooldowns == [("soul_cooldown:00000000-0000-0000-0000-000000000001", 1800, "1")] diff --git a/apps/api/tests/unit/test_async_tasks.py b/apps/api/tests/unit/test_async_tasks.py new file mode 100644 index 0000000..32c704c --- /dev/null +++ b/apps/api/tests/unit/test_async_tasks.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import asyncio +import logging + +import pytest + +from app.utils.async_tasks import create_logged_task + + +@pytest.mark.asyncio +async def test_create_logged_task_logs_exceptions(caplog) -> None: + async def _boom() -> None: + raise RuntimeError("boom") + + logger = logging.getLogger("tests.async_tasks") + + with caplog.at_level(logging.DEBUG): + task = create_logged_task(_boom(), logger=logger, description="background task") + with pytest.raises(RuntimeError, match="boom"): + await task + + assert "background task failed: boom" in caplog.text + + +@pytest.mark.asyncio +async def test_create_logged_task_logs_cancellation(caplog) -> None: + logger = logging.getLogger("tests.async_tasks") + + with caplog.at_level(logging.DEBUG): + task = create_logged_task( + asyncio.sleep(10), + logger=logger, + description="sleep task", + ) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert "sleep task cancelled" in caplog.text diff --git a/apps/api/tests/unit/test_build_desktop_sidecar.py b/apps/api/tests/unit/test_build_desktop_sidecar.py new file mode 100644 index 0000000..d305b17 --- /dev/null +++ b/apps/api/tests/unit/test_build_desktop_sidecar.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import importlib.util +import stat +from pathlib import Path + + +def load_sidecar_script(): + script_path = Path(__file__).resolve().parents[2] / "scripts" / "build_desktop_sidecar.py" + spec = importlib.util.spec_from_file_location("build_desktop_sidecar", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_sidecar_script_resolves_repo_root_and_output_paths() -> None: + module = load_sidecar_script() + repo_root = Path(__file__).resolve().parents[4] + + assert module.ROOT == repo_root + assert module.API_DIR == repo_root / "apps" / "api" + assert module.BINARIES_DIR == repo_root / "apps" / "desktop" / "src-tauri" / "binaries" + assert module.SIDECAR_BASENAME == "lyranote-api-desktop" + assert module.RUNTIME_DIR_NAME == "lyranote-api-desktop-runtime" + assert module.MIN_PYTHON_VERSION == (3, 12) + + +def test_sidecar_script_requires_python_312_or_newer() -> None: + module = load_sidecar_script() + + assert module.is_supported_python((3, 12, 0)) + assert module.is_supported_python((3, 13, 0)) + assert not module.is_supported_python((3, 11, 9)) + + +def test_sidecar_paths_follow_tauri_external_bin_and_resource_layout(tmp_path: Path) -> None: + module = load_sidecar_script() + + wrapper_path, runtime_dir = module.sidecar_paths(tmp_path, "aarch64-apple-darwin") + + assert wrapper_path == tmp_path / "lyranote-api-desktop-aarch64-apple-darwin" + assert runtime_dir == tmp_path / "lyranote-api-desktop-runtime" + + +def test_write_wrapper_points_to_bundled_and_local_runtime(tmp_path: Path) -> None: + module = load_sidecar_script() + wrapper_path = tmp_path / "lyranote-api-desktop-aarch64-apple-darwin" + + module.write_wrapper(wrapper_path) + + wrapper = wrapper_path.read_text(encoding="utf-8") + assert wrapper.startswith("#!/bin/sh") + assert "../Resources/lyranote-api-desktop-runtime/lyranote-api-desktop" in wrapper + assert "$SCRIPT_DIR/lyranote-api-desktop-runtime/lyranote-api-desktop" in wrapper + assert 'exec "$BUNDLED_RUNTIME" "$@"' in wrapper + assert 'exec "$LOCAL_RUNTIME" "$@"' in wrapper + assert wrapper_path.stat().st_mode & stat.S_IXUSR + + +def test_pyinstaller_command_collects_dynamic_app_imports(tmp_path: Path) -> None: + module = load_sidecar_script() + + command = module.pyinstaller_command( + module.API_DIR / "app" / "desktop_main.py", + tmp_path / "dist", + tmp_path / "build", + tmp_path / "spec", + ) + + assert "--onedir" in command + assert "--collect-submodules" in command + assert "app" in command + assert "--collect-data" in command + assert "aiosqlite" in command diff --git a/apps/api/tests/unit/test_celery_routing.py b/apps/api/tests/unit/test_celery_routing.py new file mode 100644 index 0000000..dbeb91e --- /dev/null +++ b/apps/api/tests/unit/test_celery_routing.py @@ -0,0 +1,22 @@ +from app.workers.celery_app import celery_app + + +def test_celery_routes_separate_ingestion_and_maintenance_queues() -> None: + routes = celery_app.conf.task_routes + + assert routes["ingest_source"]["queue"] == "ingestion" + assert routes["extract_knowledge_graph"]["queue"] == "maintenance" + assert routes["rebuild_knowledge_graph"]["queue"] == "maintenance" + assert routes["index_note"]["queue"] == "ingestion" + assert routes["postprocess_indexed_source"]["queue"] == "maintenance" + assert routes["execute_scheduled_task"]["queue"] == "scheduled" + assert routes["check_scheduled_tasks"]["queue"] == "maintenance" + assert routes["precompute_ai_suggestions"]["queue"] == "maintenance" + + +def test_beat_maintenance_jobs_are_routed_off_main_queue() -> None: + beat_schedule = celery_app.conf.beat_schedule + + assert beat_schedule["check-scheduled-tasks"]["options"]["queue"] == "maintenance" + assert beat_schedule["expire-stuck-sources"]["options"]["queue"] == "maintenance" + assert beat_schedule["precompute-ai-suggestions"]["options"]["queue"] == "maintenance" diff --git a/apps/api/tests/unit/test_chat_task_manager_persistence.py b/apps/api/tests/unit/test_chat_task_manager_persistence.py index 4d5c18e..e2e9fc3 100644 --- a/apps/api/tests/unit/test_chat_task_manager_persistence.py +++ b/apps/api/tests/unit/test_chat_task_manager_persistence.py @@ -1,12 +1,16 @@ from __future__ import annotations +import asyncio import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker -from app.agents.chat.task_manager import _snapshot_list -from app.models import Conversation, Message, User +from app.agents.chat.task_manager import cancel_message_generation_task, start_message_generation_task, _snapshot_list +from app.models import Conversation, Message, MessageGeneration, User @pytest.mark.asyncio @@ -61,3 +65,101 @@ async def test_snapshot_list_reassignment_persists_all_agent_steps_and_ui_elemen assert persisted.ui_elements == ui_elements assert len(persisted.agent_steps or []) == 3 assert len(persisted.ui_elements or []) == 2 + + +@pytest.mark.asyncio +async def test_run_message_generation_persists_cancelled_partial_output(engine, db_session, monkeypatch: pytest.MonkeyPatch) -> None: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + user = User( + id=uuid.uuid4(), + username="cancel-generation-user", + email="cancel-generation@example.com", + ) + db_session.add(user) + await db_session.flush() + + conversation = Conversation( + id=uuid.uuid4(), + user_id=user.id, + title="Cancelable Generation", + source="chat", + ) + user_message = Message( + id=uuid.uuid4(), + conversation_id=conversation.id, + role="user", + content="画一张架构图", + status="completed", + ) + assistant_message = Message( + id=uuid.uuid4(), + conversation_id=conversation.id, + role="assistant", + content="", + status="streaming", + ) + generation = MessageGeneration( + id=uuid.uuid4(), + conversation_id=conversation.id, + user_message_id=user_message.id, + assistant_message_id=assistant_message.id, + user_id=user.id, + status="running", + model="gpt-test", + ) + assistant_message.generation_id = generation.id + db_session.add_all([conversation, user_message, assistant_message, generation]) + await db_session.commit() + + blocker = asyncio.Event() + first_token_consumed = asyncio.Event() + + async def fake_run_agent(**_kwargs): + yield {"type": "token", "content": "部分输出"} + first_token_consumed.set() + await blocker.wait() + yield {"type": "done"} + + async def fake_prompt_context(*_args, **_kwargs): + return SimpleNamespace(all_memories=[]) + + monkeypatch.setattr("app.agents.chat.task_manager.AsyncSessionLocal", session_factory) + monkeypatch.setattr( + "app.agents.core.react_agent.classify_agent_execution_route", + lambda **_kwargs: SimpleNamespace(mode="single"), + ) + monkeypatch.setattr("app.agents.core.react_agent.run_agent", fake_run_agent) + monkeypatch.setattr( + "app.services.conversation_service.ConversationService._load_history", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr( + "app.services.conversation_service._load_prompt_context_safely", + fake_prompt_context, + ) + + task = start_message_generation_task( + str(generation.id), + content=user_message.content, + global_search=True, + tool_hint=None, + attachment_ids=None, + thinking_enabled=None, + trace_id=None, + ) + + await asyncio.wait_for(first_token_consumed.wait(), timeout=1) + cancel_message_generation_task(str(generation.id)) + await task + + async with session_factory() as check_session: + persisted_generation = await check_session.get(MessageGeneration, generation.id) + persisted_message = await check_session.get(Message, assistant_message.id) + assert persisted_generation is not None + assert persisted_generation.status == "cancelled" + assert persisted_generation.completed_at is not None + + assert persisted_message is not None + assert persisted_message.status == "completed" + assert persisted_message.content == "部分输出" diff --git a/apps/api/tests/unit/test_config_service.py b/apps/api/tests/unit/test_config_service.py new file mode 100644 index 0000000..89580b9 --- /dev/null +++ b/apps/api/tests/unit/test_config_service.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import select + +from app.config import settings +from app.models import AppConfig +from app.services.config_service import ConfigService, MASKED_VALUE + + +@pytest.mark.asyncio +async def test_get_runtime_config_ignores_removed_custom_system_prompt_key( + db_session, +) -> None: + db_session.add_all( + [ + AppConfig(key="ai_name", value="Kami"), + AppConfig(key="custom_system_prompt", value="stale prompt"), + ] + ) + await db_session.commit() + + config = await ConfigService(db_session).get_runtime_config() + + assert config["ai_name"] == "Kami" + assert "custom_system_prompt" not in config + + +@pytest.mark.asyncio +async def test_update_runtime_config_uses_canonical_storage_key_and_skips_mask( + db_session, + monkeypatch, +) -> None: + monkeypatch.setattr(settings, "storage_s3_region", "us-east-1") + + db_session.add(AppConfig(key="storage_region", value="legacy-region")) + await db_session.commit() + + service = ConfigService(db_session) + await service.update_runtime_config( + { + "storage_region": "ap-southeast-1", + "smtp_password": MASKED_VALUE, + "notebook_appearance_defaults": '{"themeId":"paper-serif"}', + } + ) + + rows = ( + await db_session.execute( + select(AppConfig).where( + AppConfig.key.in_( + { + "storage_region", + "storage_s3_region", + "smtp_password", + "notebook_appearance_defaults", + } + ) + ) + ) + ).scalars().all() + row_map = {row.key: row.value for row in rows} + + assert row_map["storage_s3_region"] == "ap-southeast-1" + assert row_map["notebook_appearance_defaults"] == '{"themeId":"paper-serif"}' + assert "storage_region" not in row_map + assert "smtp_password" not in row_map + assert settings.storage_s3_region == "ap-southeast-1" + + +@pytest.mark.asyncio +async def test_saved_llm_connection_uses_shared_helper_and_db_values( + db_session, + monkeypatch, +) -> None: + db_session.add_all( + [ + AppConfig(key="llm_provider", value="litellm"), + AppConfig(key="openai_api_key", value="sk-live"), + AppConfig(key="openai_base_url", value="https://example.test/v1"), + AppConfig(key="llm_model", value="gemini/flash"), + ] + ) + await db_session.commit() + + helper = AsyncMock(return_value="pong") + monkeypatch.setattr( + "app.services.config_service._run_chat_connection_test", + helper, + ) + + result = await ConfigService(db_session).test_saved_llm_connection() + + assert result == {"ok": True, "model": "gemini/flash", "message": "pong"} + helper.assert_awaited_once_with( + provider="litellm", + api_key="sk-live", + base_url="https://example.test/v1", + model="gemini/flash", + ) diff --git a/apps/api/tests/unit/test_conversation_service_memory_fallback.py b/apps/api/tests/unit/test_conversation_service_memory_fallback.py index 696f08b..49b3708 100644 --- a/apps/api/tests/unit/test_conversation_service_memory_fallback.py +++ b/apps/api/tests/unit/test_conversation_service_memory_fallback.py @@ -5,7 +5,8 @@ import pytest -from app.services.conversation_service import _load_user_memories_safely +from app.agents.memory import build_prompt_context_bundle +from app.services.conversation_service import _load_prompt_context_safely class _FakeSession: @@ -15,29 +16,45 @@ async def begin_nested(self): @pytest.mark.asyncio -async def test_load_user_memories_safely_falls_back_to_empty_on_context_error(monkeypatch): +async def test_load_prompt_context_safely_falls_back_to_empty_bundle_on_error(monkeypatch): async def _boom(*_args, **_kwargs): - raise RuntimeError("column user_memories.memory_kind does not exist") + raise RuntimeError("prompt context exploded") - monkeypatch.setattr("app.agents.memory.build_memory_context", _boom) + monkeypatch.setattr("app.agents.memory.load_prompt_context", _boom) - result = await _load_user_memories_safely( + result = await _load_prompt_context_safely( _FakeSession(), uuid4(), current_query="继续", - scene="research", + scene="chat", ) - assert result == [] + assert result.scene == "chat" + assert result.all_memories == [] + assert result.portrait is None @pytest.mark.asyncio -async def test_load_user_memories_safely_uses_legacy_loader_when_query_missing(monkeypatch): - async def _fake_get_user_memories(*_args, **_kwargs): - return [{"key": "writing_style", "value": "简洁", "confidence": 0.9}] +async def test_load_prompt_context_safely_returns_loaded_bundle(monkeypatch): + expected = build_prompt_context_bundle( + scene="research", + user_memories=[{"key": "writing_style", "value": "简洁", "confidence": 0.9}], + conversation_summary="older summary", + ) - monkeypatch.setattr("app.agents.memory.get_user_memories", _fake_get_user_memories) + async def _fake_loader(*_args, **_kwargs): + return expected - result = await _load_user_memories_safely(_FakeSession(), uuid4()) + monkeypatch.setattr("app.agents.memory.load_prompt_context", _fake_loader) + + result = await _load_prompt_context_safely( + _FakeSession(), + uuid4(), + current_query="继续", + scene="research", + include_portrait=True, + ) - assert result == [{"key": "writing_style", "value": "简洁", "confidence": 0.9}] + assert result is expected + assert result.scene == "research" + assert result.all_memories == expected.all_memories diff --git a/apps/api/tests/unit/test_deep_research_router.py b/apps/api/tests/unit/test_deep_research_router.py index 26bc328..aca800e 100644 --- a/apps/api/tests/unit/test_deep_research_router.py +++ b/apps/api/tests/unit/test_deep_research_router.py @@ -6,6 +6,7 @@ import pytest +from app.agents.memory import build_prompt_context_bundle from app.domains.ai.routers.research import create_deep_research, save_deep_research_sources from app.domains.ai.schemas import DeepResearchRequest, SaveDeepResearchSourcesRequest from app.models import Conversation, Message, ResearchTask @@ -35,8 +36,8 @@ def _close_background_task(coro): return None with patch( - "app.agents.memory.build_memory_context", - new=AsyncMock(return_value=[]), + "app.agents.memory.load_prompt_context", + new=AsyncMock(return_value=build_prompt_context_bundle(scene="research")), ), patch( "app.domains.ai.routers.research.run_research_task", new=AsyncMock(return_value=None), diff --git a/apps/api/tests/unit/test_desktop_chat_service.py b/apps/api/tests/unit/test_desktop_chat_service.py new file mode 100644 index 0000000..1575ebc --- /dev/null +++ b/apps/api/tests/unit/test_desktop_chat_service.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.services.desktop_chat_service import DesktopChatService + + +@pytest.mark.asyncio +async def test_answer_locally_returns_offline_summary_with_citations() -> None: + service = DesktopChatService(SimpleNamespace(), uuid.uuid4()) + service.knowledge_service.search_local = AsyncMock( # type: ignore[method-assign] + return_value={ + "query": "transformer", + "mode": "fts5", + "items": [ + { + "chunk_id": "chunk-1", + "source_id": "source-1", + "notebook_id": "notebook-1", + "source_title": "Attention Is All You Need", + "source_type": "pdf", + "chunk_index": 0, + "content": "Transformer attention replaces recurrence in sequence modeling.", + "excerpt": "Transformer attention replaces recurrence in sequence modeling.", + "rank": 0.12, + "metadata": {"page": 3, "section": "Architecture"}, + } + ], + } + ) + + result = await service.answer_locally(query="transformer") + + assert result["mode"] == "offline_cache" + assert "Attention Is All You Need" in result["answer"] + assert "第3页" in result["answer"] + assert result["citations"] == [ + { + "source_id": "source-1", + "chunk_id": "chunk-1", + "source_title": "Attention Is All You Need", + "excerpt": "Transformer attention replaces recurrence in sequence modeling.", + "metadata": {"page": 3, "section": "Architecture"}, + } + ] + + +@pytest.mark.asyncio +async def test_answer_locally_returns_empty_state_copy_when_no_hits() -> None: + service = DesktopChatService(SimpleNamespace(), uuid.uuid4()) + service.knowledge_service.search_local = AsyncMock( # type: ignore[method-assign] + return_value={"query": "unknown", "mode": "fts5", "items": []} + ) + + result = await service.answer_locally(query="unknown") + + assert result["mode"] == "offline_cache" + assert result["citations"] == [] + assert "没有在本地知识库里找到" in result["answer"] diff --git a/apps/api/tests/unit/test_desktop_local_search.py b/apps/api/tests/unit/test_desktop_local_search.py new file mode 100644 index 0000000..74a4edf --- /dev/null +++ b/apps/api/tests/unit/test_desktop_local_search.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.services.desktop_knowledge_service import DesktopKnowledgeService +from app.services.desktop_runtime_service import desktop_state_store + + +class _ScalarResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + +class _ChunkResult: + def __init__(self, items): + self._items = items + + def scalars(self): + return self + + def all(self): + return self._items + + +@pytest.fixture(autouse=True) +def desktop_state_env(monkeypatch, tmp_path): + monkeypatch.setattr("app.config.settings.runtime_profile", "desktop") + monkeypatch.setattr( + "app.config.settings.desktop_state_dir_override", + str(tmp_path / "desktop-state"), + ) + yield + + +@pytest.mark.asyncio +async def test_sync_source_chunks_populates_local_search_index() -> None: + user_id = uuid.uuid4() + source_id = uuid.uuid4() + notebook_id = uuid.uuid4() + source = SimpleNamespace( + id=source_id, + notebook_id=notebook_id, + title="Attention Is All You Need", + type="pdf", + ) + chunk = SimpleNamespace( + id=uuid.uuid4(), + chunk_index=0, + content="Transformer attention replaces recurrence in sequence modeling.", + metadata_={"page": 3, "section": "Architecture"}, + ) + db = SimpleNamespace( + execute=AsyncMock( + side_effect=[ + _ScalarResult(source), + _ChunkResult([chunk]), + ] + ) + ) + service = DesktopKnowledgeService(db, user_id) + + synced = await service.sync_source_chunks(source_id) + assert synced == 1 + + items = desktop_state_store.search_local_chunks( + user_id=str(user_id), + query="Transformer attention", + limit=5, + ) + assert len(items) == 1 + assert items[0]["source_title"] == "Attention Is All You Need" + assert items[0]["metadata"] == {"page": 3, "section": "Architecture"} + + +@pytest.mark.asyncio +async def test_search_local_falls_back_to_like_for_chinese_query(monkeypatch) -> None: + user_id = uuid.uuid4() + source_id = uuid.uuid4() + notebook_id = uuid.uuid4() + desktop_state_store.sync_source_chunks( + user_id=str(user_id), + source_id=str(source_id), + notebook_id=str(notebook_id), + source_title="深度学习笔记", + source_type="md", + chunks=[ + { + "chunk_id": str(uuid.uuid4()), + "chunk_index": 0, + "content": "这段内容介绍深度学习模型的训练方法与参数更新。", + "metadata": {"section": "训练"}, + } + ], + ) + + service = DesktopKnowledgeService(object(), user_id) + monkeypatch.setattr(service, "ensure_local_index_warmed", AsyncMock(return_value=0)) + + result = await service.search_local(query="深度学习", limit=5) + + assert result["mode"] == "fts5" + assert len(result["items"]) == 1 + assert result["items"][0]["source_title"] == "深度学习笔记" + assert "深度学习" in result["items"][0]["excerpt"] diff --git a/apps/api/tests/unit/test_desktop_main.py b/apps/api/tests/unit/test_desktop_main.py new file mode 100644 index 0000000..ea70b4c --- /dev/null +++ b/apps/api/tests/unit/test_desktop_main.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import os + +from app.desktop_main import configure_desktop_environment + + +def test_configure_desktop_environment_defaults_to_local_sqlite(monkeypatch, tmp_path) -> None: + state_dir = tmp_path / "desktop-state" + monkeypatch.setenv("DESKTOP_STATE_DIR_OVERRIDE", str(state_dir)) + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("STORAGE_LOCAL_PATH", raising=False) + monkeypatch.delenv("MEMORY_DIR", raising=False) + + configure_desktop_environment() + + assert state_dir.exists() + assert os.environ["DATABASE_URL"] == ( + "sqlite+aiosqlite:///" + str((state_dir / "runtime-api.sqlite3").resolve()) + ) + assert os.environ["RUNTIME_PROFILE"] == "desktop" + assert os.environ["MONITORING_ENABLED"] == "false" + assert os.environ["STORAGE_LOCAL_PATH"] == str((state_dir / "storage").resolve()) + assert os.environ["MEMORY_DIR"] == str((state_dir / "memory").resolve()) + + +def test_configure_desktop_environment_preserves_explicit_database_url(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("DESKTOP_STATE_DIR_OVERRIDE", str(tmp_path / "desktop-state")) + monkeypatch.setenv("DATABASE_URL", "sqlite+aiosqlite:////tmp/custom.sqlite3") + + configure_desktop_environment() + + assert os.environ["DATABASE_URL"] == "sqlite+aiosqlite:////tmp/custom.sqlite3" diff --git a/apps/api/tests/unit/test_desktop_runtime_service.py b/apps/api/tests/unit/test_desktop_runtime_service.py new file mode 100644 index 0000000..8ed9932 --- /dev/null +++ b/apps/api/tests/unit/test_desktop_runtime_service.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import sqlite3 +import threading + +from app.config import settings +from app.services.desktop_runtime_service import DesktopStateStore + + +def test_desktop_state_store_initialization_does_not_deadlock(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(settings, "desktop_state_dir_override", str(tmp_path / "desktop-state")) + + result: dict[str, object] = {} + errors: list[BaseException] = [] + + def _build_store() -> None: + try: + store = DesktopStateStore() + result["db_path"] = store.db_path + except BaseException as exc: # pragma: no cover - assertion path captures this + errors.append(exc) + + thread = threading.Thread(target=_build_store, daemon=True) + thread.start() + thread.join(timeout=2) + + assert not thread.is_alive(), "DesktopStateStore initialization deadlocked" + assert not errors + + db_path = result["db_path"] + assert db_path == (tmp_path / "desktop-state" / "runtime-state.sqlite3").resolve() + + with sqlite3.connect(db_path) as conn: + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'view')" + ).fetchall() + } + + assert { + "desktop_jobs", + "watch_folders", + "recent_imports", + "watched_file_state", + "desktop_chunk_fts", + }.issubset(tables) diff --git a/apps/api/tests/unit/test_desktop_service.py b/apps/api/tests/unit/test_desktop_service.py new file mode 100644 index 0000000..867f0a8 --- /dev/null +++ b/apps/api/tests/unit/test_desktop_service.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import uuid +from unittest.mock import AsyncMock + +import pytest + +from app.exceptions import BadRequestError +from app.services.desktop_runtime_service import desktop_state_store +from app.services.desktop_service import DesktopService + + +class _AsyncSessionStub: + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +@pytest.fixture(autouse=True) +def desktop_state_env(monkeypatch, tmp_path): + monkeypatch.setattr("app.config.settings.runtime_profile", "desktop") + monkeypatch.setattr( + "app.config.settings.desktop_state_dir_override", + str(tmp_path / "desktop-state"), + ) + monkeypatch.setattr( + "app.services.desktop_agent_service.desktop_job_manager.ensure_started", + lambda: None, + ) + monkeypatch.setattr( + "app.services.desktop_knowledge_service.desktop_job_manager.ensure_started", + lambda: None, + ) + yield + + +def test_watch_folder_registry_adds_lists_and_deletes(tmp_path) -> None: + service = DesktopService() + user_id = str(uuid.uuid4()) + watched_dir = tmp_path / "notes" + watched_dir.mkdir() + + created = service.create_watch_folder(user_id=user_id, path=str(watched_dir)) + assert created["path"] == str(watched_dir.resolve()) + assert created["name"] == "notes" + assert created["last_synced_at"] is None + assert created["last_error"] is None + assert created["is_active"] is True + + listed = service.list_watch_folders(user_id=user_id) + assert len(listed["items"]) == 1 + assert listed["items"][0]["id"] == created["id"] + + service.delete_watch_folder(user_id=user_id, folder_id=created["id"]) + assert service.list_watch_folders(user_id=user_id) == {"items": []} + + +def test_watch_folder_registry_rejects_duplicates(tmp_path) -> None: + service = DesktopService() + user_id = str(uuid.uuid4()) + watched_dir = tmp_path / "papers" + watched_dir.mkdir() + + service.create_watch_folder(user_id=user_id, path=str(watched_dir)) + + with pytest.raises(BadRequestError, match="已注册"): + service.create_watch_folder(user_id=user_id, path=str(watched_dir)) + + +def test_jobs_list_and_cancel_queued_job() -> None: + service = DesktopService() + user_id = str(uuid.uuid4()) + job = desktop_state_store.create_job( + user_id=user_id, + kind="import", + label="索引资料:paper.pdf", + resource_id="source-1", + payload={"source_id": "source-1"}, + ) + + listed = service.list_jobs(user_id=user_id) + assert listed["items"][0]["id"] == job["id"] + assert listed["items"][0]["state"] == "queued" + + cancelled = service.cancel_job(user_id=user_id, job_id=str(job["id"])) + assert cancelled == {"cancelled": True, "reason": None} + + listed_after = service.list_jobs(user_id=user_id) + assert listed_after["items"][0]["state"] == "cancelled" + + +@pytest.mark.asyncio +async def test_import_watch_folder_path_skips_repeated_unchanged_files( + monkeypatch, + tmp_path, +) -> None: + service = DesktopService() + user_id = str(uuid.uuid4()) + watched_dir = tmp_path / "incoming" + watched_dir.mkdir() + file_path = watched_dir / "report.md" + file_path.write_text("# report", encoding="utf-8") + + service.create_watch_folder(user_id=user_id, path=str(watched_dir)) + monkeypatch.setattr("app.services.desktop_knowledge_service.AsyncSessionLocal", _AsyncSessionStub) + async def _import(path: str, sha256: str | None = None): + source = type("SourceStub", (), {"id": uuid.uuid4()})() + desktop_state_store.record_import( + user_id=user_id, + path=path, + source_id=str(source.id), + title=file_path.name, + sha256=sha256, + ) + return source + + import_mock = AsyncMock(side_effect=_import) + monkeypatch.setattr( + "app.services.desktop_knowledge_service.SourceService.import_global_source_path", + import_mock, + ) + + first = await service.import_watch_folder_path(user_id=user_id, path=str(file_path)) + second = await service.import_watch_folder_path(user_id=user_id, path=str(file_path)) + + assert first["state"] == "queued" + assert second == {"state": "skipped", "path": str(file_path.resolve())} + assert import_mock.await_count == 1 + + folder = service.list_watch_folders(user_id=user_id)["items"][0] + assert folder["last_synced_at"] is not None + assert folder["last_error"] is None + + +def test_recent_imports_reflect_recorded_history(tmp_path) -> None: + service = DesktopService() + user_id = str(uuid.uuid4()) + file_path = tmp_path / "summary.txt" + file_path.write_text("summary", encoding="utf-8") + + desktop_state_store.record_import( + user_id=user_id, + path=str(file_path), + source_id="source-99", + title="summary.txt", + ) + + recent = service.list_recent_imports(user_id=user_id) + assert recent["items"] == [ + { + "path": str(file_path.resolve()), + "source_id": "source-99", + "title": "summary.txt", + "imported_at": recent["items"][0]["imported_at"], + } + ] + + +def test_inspect_local_file_detects_duplicate_content(tmp_path) -> None: + service = DesktopService() + user_id = str(uuid.uuid4()) + original = tmp_path / "paper-a.pdf" + duplicate = tmp_path / "paper-b.pdf" + original.write_text("same-content", encoding="utf-8") + duplicate.write_text("same-content", encoding="utf-8") + + desktop_state_store.record_import( + user_id=user_id, + path=str(original), + source_id="source-1", + title="paper-a.pdf", + sha256="digest-1", + ) + + inspection = service.inspect_local_file( + user_id=user_id, + path=str(duplicate), + sha256="digest-1", + ) + + assert inspection == { + "state": "duplicate", + "path": str(duplicate.resolve()), + "source_id": "source-1", + "matched_path": str(original.resolve()), + "matched_title": "paper-a.pdf", + "sha256": "digest-1", + } + + +@pytest.mark.asyncio +async def test_import_watch_folder_path_returns_duplicate_without_reimport( + monkeypatch, + tmp_path, +) -> None: + service = DesktopService() + user_id = str(uuid.uuid4()) + watched_dir = tmp_path / "incoming" + watched_dir.mkdir() + original = watched_dir / "paper-a.md" + duplicate = watched_dir / "paper-b.md" + original.write_text("# same", encoding="utf-8") + duplicate.write_text("# same", encoding="utf-8") + + service.create_watch_folder(user_id=user_id, path=str(watched_dir)) + desktop_state_store.record_import( + user_id=user_id, + path=str(original), + source_id="source-existing", + title="paper-a.md", + sha256="known-digest", + ) + + monkeypatch.setattr( + "app.services.desktop_knowledge_service.compute_file_sha256", + lambda path: "known-digest", + ) + import_mock = AsyncMock() + monkeypatch.setattr( + "app.services.desktop_knowledge_service.SourceService.import_global_source_path", + import_mock, + ) + + result = await service.import_watch_folder_path(user_id=user_id, path=str(duplicate)) + + assert result == { + "state": "duplicate", + "path": str(duplicate.resolve()), + "source_id": "source-existing", + } + import_mock.assert_not_awaited() diff --git a/apps/api/tests/unit/test_ingestion_pipeline.py b/apps/api/tests/unit/test_ingestion_pipeline.py new file mode 100644 index 0000000..2b151a3 --- /dev/null +++ b/apps/api/tests/unit/test_ingestion_pipeline.py @@ -0,0 +1,45 @@ +import zipfile +from io import BytesIO + +from app.agents.rag import ingestion + + +def test_auto_split_prefers_recursive_splitter(monkeypatch) -> None: + def _boom(*_args, **_kwargs): + raise AssertionError("auto splitter should not call semantic splitter") + + monkeypatch.setattr(ingestion, "_semantic_split", _boom) + monkeypatch.setattr(ingestion, "_recursive_split", lambda *_args, **_kwargs: ["chunk-a"]) + + chunks = ingestion._split_text("example text", 128, 16, splitter_type="auto") + + assert chunks == ["chunk-a"] + + +def test_build_fallback_summary_condenses_and_truncates() -> None: + text = "第一段内容。\n\n第二段内容。 " * 30 + + summary = ingestion._build_fallback_summary(text, limit=40) + + assert "\n" not in summary + assert len(summary) <= 40 + assert summary.endswith("...") + + +def test_parse_docx_bytes_extracts_paragraph_text() -> None: + xml = """ + + + 第一段内容 + 第二段内容 + + + """ + buf = BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("word/document.xml", xml) + + text, meta = ingestion._parse_docx_bytes(buf.getvalue()) + + assert text == "第一段内容\n\n第二段内容" + assert meta == [] diff --git a/apps/api/tests/unit/test_ingestion_tasks.py b/apps/api/tests/unit/test_ingestion_tasks.py new file mode 100644 index 0000000..56d3084 --- /dev/null +++ b/apps/api/tests/unit/test_ingestion_tasks.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.workers.tasks.ingestion import _expire_stuck_sources_impl, _mark_source_failed + + +class _ScalarList: + def __init__(self, values): + self._values = values + + def all(self): + return self._values + + +class _ExecuteResult: + def __init__(self, values): + self._values = values + + def scalars(self): + return _ScalarList(self._values) + + +def test_mark_source_failed_sets_status_without_metadata_field() -> None: + source = SimpleNamespace(status="processing", summary=None) + + _mark_source_failed(source, "indexing_timeout") + + assert source.status == "failed" + assert "索引超时" in source.summary + + +def test_mark_source_failed_sets_missing_storage_message() -> None: + source = SimpleNamespace(status="processing", summary=None) + + _mark_source_failed(source, "storage_missing") + + assert source.status == "failed" + assert "原始文件不存在" in source.summary + + +@pytest.mark.asyncio +async def test_expire_stuck_sources_marks_pending_and_processing_failed() -> None: + old_time = datetime.now(UTC) - timedelta(minutes=30) + source_a = SimpleNamespace(status="pending", summary=None, updated_at=old_time) + source_b = SimpleNamespace(status="processing", summary="existing", updated_at=old_time) + + db = SimpleNamespace( + execute=AsyncMock(return_value=_ExecuteResult([source_a, source_b])), + commit=AsyncMock(return_value=None), + ) + + count = await _expire_stuck_sources_impl(db) + + assert count == 2 + assert source_a.status == "failed" + assert "索引超时" in source_a.summary + assert source_b.status == "failed" + assert source_b.summary == "existing" + db.commit.assert_awaited_once() diff --git a/apps/api/tests/unit/test_mcp_client.py b/apps/api/tests/unit/test_mcp_client.py new file mode 100644 index 0000000..52c2827 --- /dev/null +++ b/apps/api/tests/unit/test_mcp_client.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from app.mcp.client import MCPClientManager + + +@pytest.mark.asyncio +async def test_call_tool_propagates_cancellation(monkeypatch: pytest.MonkeyPatch) -> None: + manager = MCPClientManager() + + async def cancelled_run(*_args, **_kwargs): + raise asyncio.CancelledError() + + monkeypatch.setattr(manager, "_run", cancelled_run) + + with pytest.raises(asyncio.CancelledError): + await manager.call_tool( + config=object(), # type: ignore[arg-type] + tool_name="server__read_file", + arguments={}, + ) diff --git a/apps/api/tests/unit/test_memory_service.py b/apps/api/tests/unit/test_memory_service.py new file mode 100644 index 0000000..da1e120 --- /dev/null +++ b/apps/api/tests/unit/test_memory_service.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import uuid +from pathlib import Path + +import pytest +from sqlalchemy import select + +from app.auth import hash_password +from app.models import AppConfig, User, UserMemory +from app.services.memory_service import MemoryService + + +def _memory_doc(content: str) -> str: + return "# 我的 AI 记忆\n\n" + content.strip() + "\n" + + +@pytest.mark.asyncio +async def test_update_memory_doc_syncs_and_removes_deleted_sections( + db_session, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + monkeypatch.setattr("app.config.settings.memory_dir", str(tmp_path)) + + async def _fake_embed_query(_text: str) -> list[float]: + return [0.0] * 1536 + + monkeypatch.setattr("app.providers.embedding.embed_query", _fake_embed_query) + + user = User( + id=uuid.uuid4(), + username=f"memsvc_{uuid.uuid4().hex[:6]}", + email=f"memsvc_{uuid.uuid4().hex[:6]}@test.com", + name="Memory Service User", + password_hash=hash_password("password123"), + ) + db_session.add(user) + await db_session.commit() + + service = MemoryService(db_session, user.id) + + await service.update_memory_doc( + _memory_doc( + """ +## About Me +I build agents. + +## Constraints +Keep answers short. +""" + ) + ) + await db_session.commit() + + rows = ( + await db_session.execute( + select(UserMemory).where(UserMemory.user_id == user.id).order_by(UserMemory.key.asc()) + ) + ).scalars().all() + assert [row.key for row in rows] == ["file_about_me", "file_constraints"] + + await service.update_memory_doc( + _memory_doc( + """ +## Constraints +Keep answers short. +""" + ) + ) + await db_session.commit() + + rows = ( + await db_session.execute( + select(UserMemory).where(UserMemory.user_id == user.id).order_by(UserMemory.key.asc()) + ) + ).scalars().all() + assert [row.key for row in rows] == ["file_constraints"] + + sync_meta = ( + await db_session.execute( + select(AppConfig).where(AppConfig.key == f"memory_doc_sync_mtime:{user.id}") + ) + ).scalar_one_or_none() + assert sync_meta is not None + assert sync_meta.value + + +@pytest.mark.asyncio +async def test_update_memory_doc_cleans_diary_and_file_duplicates( + db_session, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + monkeypatch.setattr("app.config.settings.memory_dir", str(tmp_path)) + + async def _fake_embed_query(_text: str) -> list[float]: + return [0.0] * 1536 + + monkeypatch.setattr("app.providers.embedding.embed_query", _fake_embed_query) + + user = User( + id=uuid.uuid4(), + username=f"memdup_{uuid.uuid4().hex[:6]}", + email=f"memdup_{uuid.uuid4().hex[:6]}@test.com", + name="Memory Cleanup User", + password_hash=hash_password("password123"), + ) + db_session.add(user) + await db_session.flush() + + db_session.add( + UserMemory( + id=uuid.uuid4(), + user_id=user.id, + key="file_about_me", + value="I build agents.", + confidence=0.9, + memory_type="fact", + memory_kind="profile", + access_count=0, + source="conversation", + evidence="message-1", + conflict_flag=False, + ) + ) + db_session.add( + UserMemory( + id=uuid.uuid4(), + user_id=user.id, + key="diary_2026_04_22", + value="old diary memory", + confidence=0.5, + memory_type="fact", + memory_kind="project_state", + access_count=0, + source="file", + evidence="diary", + conflict_flag=False, + ) + ) + await db_session.commit() + + service = MemoryService(db_session, user.id) + await service.update_memory_doc( + _memory_doc( + """ +## About Me +I build agents. + +## Constraints +Keep answers short. +""" + ) + ) + await db_session.commit() + + rows = ( + await db_session.execute( + select(UserMemory).where(UserMemory.user_id == user.id).order_by(UserMemory.key.asc(), UserMemory.source.asc()) + ) + ).scalars().all() + + assert [(row.key, row.source) for row in rows] == [ + ("file_about_me", "conversation"), + ("file_constraints", "file"), + ] diff --git a/apps/api/tests/unit/test_message_generation_service.py b/apps/api/tests/unit/test_message_generation_service.py index fbc36d5..abe19f8 100644 --- a/apps/api/tests/unit/test_message_generation_service.py +++ b/apps/api/tests/unit/test_message_generation_service.py @@ -128,3 +128,56 @@ async def test_subscribe_message_generation_replays_persisted_events(monkeypatch assert json.loads(lines[0].removeprefix("data: ").strip())["type"] == "token" assert json.loads(lines[1].removeprefix("data: ").strip())["type"] == "done" assert lines[2] == "data: [DONE]\n\n" + + +@pytest.mark.asyncio +async def test_cancel_message_generation_deletes_empty_placeholder_when_no_task(monkeypatch) -> None: + generation_id = uuid.uuid4() + assistant_message_id = uuid.uuid4() + generation = SimpleNamespace( + id=generation_id, + status="running", + assistant_message_id=assistant_message_id, + completed_at=None, + error_message="boom", + ) + assistant_message = SimpleNamespace( + id=assistant_message_id, + content="", + reasoning=None, + citations=None, + agent_steps=None, + speed=None, + mind_map=None, + diagram=None, + mcp_result=None, + ui_elements=None, + status="streaming", + ) + + async def fake_get(model, item_id): + if model is Message and item_id == assistant_message_id: + return assistant_message + return None + + db = SimpleNamespace( + get=AsyncMock(side_effect=fake_get), + refresh=AsyncMock(return_value=None), + delete=AsyncMock(return_value=None), + commit=AsyncMock(return_value=None), + ) + svc = ConversationService(db, uuid.uuid4()) + monkeypatch.setattr( + ConversationService, + "_get_owned_generation", + AsyncMock(return_value=generation), + ) + monkeypatch.setattr("app.agents.chat.cancel_message_generation_task", Mock(return_value=None)) + + await svc.cancel_message_generation(generation_id) + + assert generation.status == "cancelled" + assert generation.completed_at is not None + assert generation.error_message is None + db.delete.assert_awaited_once_with(assistant_message) + db.commit.assert_awaited_once() diff --git a/apps/api/tests/unit/test_monitoring_api.py b/apps/api/tests/unit/test_monitoring_api.py index 0cf6ef5..2fe762a 100644 --- a/apps/api/tests/unit/test_monitoring_api.py +++ b/apps/api/tests/unit/test_monitoring_api.py @@ -11,10 +11,12 @@ Base, Conversation, MessageGeneration, + Notebook, ObservabilityLLMCall, ObservabilityRun, ObservabilitySpan, ObservabilityToolCall, + Source, WorkerHeartbeat, ) from app.services.monitoring_service import ( @@ -99,6 +101,8 @@ async def test_monitoring_endpoints_return_overview_trace_detail_and_workers( trace_id=trace_id, span_name="chat.llm.stream", status="success", + component="worker", + span_kind="phase", started_at=now, finished_at=now + timedelta(seconds=1), duration_ms=1000, @@ -167,10 +171,13 @@ async def test_monitoring_endpoints_return_overview_trace_detail_and_workers( assert detail["trace_id"] == trace_id assert detail["runs"][0]["generation_id"] == str(generation_id) assert detail["spans"][0]["span_name"] == "chat.llm.stream" + assert detail["spans"][0]["component"] == "worker" + assert detail["spans"][0]["span_kind"] == "phase" assert detail["llm_calls"][0]["call_type"] == "stream_answer" assert detail["tool_calls"][0]["tool_name"] == "search_notebook_knowledge" assert detail["summary"]["total_llm_calls"] == 1 assert detail["summary"]["total_output_tokens"] == 48 + assert detail["summary"]["final_status"] == "succeeded" workers_response = await client.get("/api/v1/monitoring/workers", headers=auth_headers) workers = workers_response.json()["data"] @@ -178,6 +185,164 @@ async def test_monitoring_endpoints_return_overview_trace_detail_and_workers( assert workers[0]["status"] == "healthy" +@pytest.mark.asyncio +async def test_monitoring_traces_failures_and_workloads_support_new_trace_fields( + client, + db_session, + auth_headers, + test_user, +) -> None: + user, _ = test_user + conversation = Conversation( + id=uuid.uuid4(), + user_id=user.id, + title="Trace workload conversation", + source="chat", + ) + db_session.add(conversation) + await db_session.flush() + + generation_one = MessageGeneration( + id=uuid.uuid4(), + conversation_id=conversation.id, + user_message_id=uuid.uuid4(), + assistant_message_id=uuid.uuid4(), + user_id=user.id, + status="completed", + model="gpt-4o-mini", + ) + generation_two = MessageGeneration( + id=uuid.uuid4(), + conversation_id=conversation.id, + user_message_id=uuid.uuid4(), + assistant_message_id=uuid.uuid4(), + user_id=user.id, + status="completed", + model="gpt-4o-mini", + ) + db_session.add_all([generation_one, generation_two]) + await db_session.flush() + + notebook = Notebook( + user_id=user.id, + title="Trace Notebook", + status="active", + is_global=False, + is_system=False, + is_public=False, + ) + db_session.add(notebook) + await db_session.flush() + + source = Source( + notebook_id=notebook.id, + title="source.pdf", + type="pdf", + status="failed", + summary="source ingest failed", + storage_key="notebooks/source.pdf", + ) + db_session.add(source) + await db_session.flush() + + shared_started_at = datetime.now(UTC) - timedelta(minutes=5) + chat_run_one = await create_observability_run( + db_session, + trace_id="trace-chat-1", + run_type="chat_generation", + name="chat.first", + status="completed", + user_id=user.id, + generation_id=generation_one.id, + started_at=shared_started_at, + ) + chat_run_one.finished_at = shared_started_at + timedelta(seconds=3) + chat_run_one.duration_ms = 3000 + + chat_run_two = await create_observability_run( + db_session, + trace_id="trace-chat-2", + run_type="chat_generation", + name="chat.second", + status="error", + user_id=user.id, + generation_id=generation_two.id, + started_at=shared_started_at, + ) + chat_run_two.finished_at = shared_started_at + timedelta(seconds=4) + chat_run_two.duration_ms = 4000 + + ingest_run = await create_observability_run( + db_session, + trace_id="trace-source-1", + run_type="source_ingest", + name="source.ingest", + status="failed", + user_id=user.id, + task_id=source.id, + notebook_id=notebook.id, + started_at=shared_started_at - timedelta(seconds=5), + metadata={"origin": "upload"}, + ) + ingest_run.finished_at = shared_started_at - timedelta(seconds=1) + ingest_run.duration_ms = 4000 + db_session.add( + ObservabilitySpan( + run_id=ingest_run.id, + trace_id="trace-source-1", + span_name="source_ingest.parse", + status="error", + component="ingest", + span_kind="phase", + started_at=shared_started_at - timedelta(seconds=5), + finished_at=shared_started_at - timedelta(seconds=3), + duration_ms=2000, + error_message="parse failed", + ) + ) + await db_session.commit() + + first_page = await client.get( + "/api/v1/monitoring/traces", + params={"type": "chat_generation", "limit": 1}, + headers=auth_headers, + ) + assert first_page.status_code == 200 + first_payload = first_page.json()["data"] + assert first_payload["items"][0]["status"] in {"succeeded", "failed"} + assert first_payload["next_cursor"] + + second_page = await client.get( + "/api/v1/monitoring/traces", + params={"type": "chat_generation", "limit": 1, "cursor": first_payload["next_cursor"]}, + headers=auth_headers, + ) + assert second_page.status_code == 200 + second_payload = second_page.json()["data"] + assert second_payload["items"][0]["id"] != first_payload["items"][0]["id"] + + failures_response = await client.get( + "/api/v1/monitoring/failures", + params={"kind": "source_ingest", "notebook_id": str(notebook.id)}, + headers=auth_headers, + ) + failures = failures_response.json()["data"]["items"] + assert failures[0]["trace_id"] == "trace-source-1" + assert failures[0]["trace_available"] is True + assert failures[0]["trace_missing_reason"] is None + + workloads_response = await client.get( + "/api/v1/monitoring/workloads", + params={"kind": "source_ingest", "notebook_id": str(notebook.id)}, + headers=auth_headers, + ) + workloads = workloads_response.json()["data"] + assert workloads["summary"][0]["kind"] == "source_ingest" + assert workloads["items"][0]["trace_id"] == "trace-source-1" + assert workloads["items"][0]["trace_available"] is True + assert workloads["items"][0]["status"] == "failed" + + def test_classify_worker_status_marks_stale_and_down() -> None: now = datetime.now(UTC) diff --git a/apps/api/tests/unit/test_note_router.py b/apps/api/tests/unit/test_note_router.py new file mode 100644 index 0000000..24bcf32 --- /dev/null +++ b/apps/api/tests/unit/test_note_router.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from app.domains.note.router import _compute_word_count + + +def test_compute_word_count_counts_chinese_english_and_numbers() -> None: + assert _compute_word_count("你好 LyraNote 2026") == 4 + + +def test_compute_word_count_returns_zero_for_empty_text() -> None: + assert _compute_word_count(None) == 0 + assert _compute_word_count("") == 0 diff --git a/apps/api/tests/unit/test_notebook_router.py b/apps/api/tests/unit/test_notebook_router.py new file mode 100644 index 0000000..cf4fd74 --- /dev/null +++ b/apps/api/tests/unit/test_notebook_router.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import pytest +from sqlalchemy import select + +from app.models import Note, Notebook, Source + + +@pytest.mark.asyncio +async def test_get_notebook_returns_counts_from_single_query_path( + client, + auth_headers, + db_session, + test_user, +) -> None: + user, _ = test_user + notebook = Notebook( + user_id=user.id, + title="Systems Lab", + status="active", + ) + db_session.add(notebook) + await db_session.flush() + + db_session.add_all( + [ + Source( + notebook_id=notebook.id, + title="Architecture", + type="web", + status="indexed", + ), + Note( + notebook_id=notebook.id, + user_id=user.id, + title="Notes", + content_text="Hello world", + word_count=11, + ), + ] + ) + await db_session.commit() + + response = await client.get( + f"/api/v1/notebooks/{notebook.id}", + headers=auth_headers, + ) + + assert response.status_code == 200 + payload = response.json()["data"] + assert payload["source_count"] == 1 + assert payload["note_count"] == 1 + assert payload["word_count"] == 11 + + +@pytest.mark.asyncio +async def test_create_notebook_marks_response_as_new( + client, + auth_headers, + db_session, +) -> None: + response = await client.post( + "/api/v1/notebooks", + headers=auth_headers, + json={"title": "Research Board"}, + ) + + assert response.status_code == 201 + payload = response.json()["data"] + assert payload["is_new"] is True + assert payload["source_count"] == 0 + assert payload["note_count"] == 0 + assert payload["word_count"] == 0 + + notebook = ( + await db_session.execute(select(Notebook).where(Notebook.title == "Research Board")) + ).scalar_one() + assert notebook.title == "Research Board" diff --git a/apps/api/tests/unit/test_notebook_settings.py b/apps/api/tests/unit/test_notebook_settings.py new file mode 100644 index 0000000..a8b6659 --- /dev/null +++ b/apps/api/tests/unit/test_notebook_settings.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json + +import pytest +from sqlalchemy import select + +from app.models import AppConfig, Notebook + + +@pytest.mark.asyncio +async def test_notebook_settings_round_trip(client, auth_headers, db_session, test_user) -> None: + user, _ = test_user + notebook = Notebook( + user_id=user.id, + title="Writing Lab", + status="active", + appearance_settings={ + "font_family": "serif", + "theme_id": "paper-serif", + "font_size": "lg", + }, + ) + db_session.add(notebook) + await db_session.commit() + await db_session.refresh(notebook) + + detail_response = await client.get( + f"/api/v1/notebooks/{notebook.id}", + headers=auth_headers, + ) + + assert detail_response.status_code == 200 + assert detail_response.json()["data"]["appearance_settings"] == { + "font_family": "serif", + "theme_id": "paper-serif", + "font_size": "lg", + "content_width": None, + "line_height": None, + "paragraph_spacing": None, + "heading_scale": None, + "emphasize_title": None, + "auto_save": None, + "focus_mode_default": None, + "default_right_panel": None, + } + + update_response = await client.patch( + f"/api/v1/notebooks/{notebook.id}", + headers=auth_headers, + json={ + "appearance_settings": { + "font_family": "mono", + "theme_id": "mono-draft", + "auto_save": False, + } + }, + ) + + assert update_response.status_code == 200 + assert update_response.json()["data"]["appearance_settings"]["font_family"] == "mono" + assert update_response.json()["data"]["appearance_settings"]["auto_save"] is False + + await db_session.refresh(notebook) + refreshed = ( + await db_session.execute(select(Notebook).where(Notebook.id == notebook.id)) + ).scalar_one() + assert refreshed.appearance_settings == { + "font_family": "mono", + "theme_id": "mono-draft", + "auto_save": False, + } + + +@pytest.mark.asyncio +async def test_config_supports_notebook_appearance_defaults(client, auth_headers, db_session) -> None: + payload = json.dumps( + { + "fontFamily": "serif", + "themeId": "paper-serif", + "autoSave": False, + } + ) + + patch_response = await client.patch( + "/api/v1/config", + headers=auth_headers, + json={"data": {"notebook_appearance_defaults": payload}}, + ) + + assert patch_response.status_code == 204 + + stored = ( + await db_session.execute( + select(AppConfig).where(AppConfig.key == "notebook_appearance_defaults") + ) + ).scalar_one() + assert stored.value == payload + + get_response = await client.get("/api/v1/config", headers=auth_headers) + + assert get_response.status_code == 200 + assert get_response.json()["data"]["data"]["notebook_appearance_defaults"] == payload diff --git a/apps/api/tests/unit/test_prompt_context_bundle.py b/apps/api/tests/unit/test_prompt_context_bundle.py new file mode 100644 index 0000000..1f2e06d --- /dev/null +++ b/apps/api/tests/unit/test_prompt_context_bundle.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import uuid + +import pytest + +from app.agents.memory import build_prompt_context_bundle, load_prompt_context +from app.auth import hash_password +from app.models import AppConfig, User + + +def test_build_prompt_context_bundle_splits_identity_and_long_term_memories() -> None: + bundle = build_prompt_context_bundle( + scene="chat", + user_memories=[ + {"key": "preferred_ai_name", "value": "Kami", "confidence": 0.9, "source": "conversation"}, + {"key": "writing_style", "value": "简洁", "confidence": 0.8, "source": "conversation"}, + {"key": "writing_style", "value": "简洁", "confidence": 0.6, "source": "file"}, + {"key": "diary_2026_04_22", "value": "old summary", "confidence": 0.5, "source": "file"}, + ], + ) + + assert [memory["key"] for memory in bundle.identity_memories] == ["preferred_ai_name"] + assert [memory["key"] for memory in bundle.long_term_memories] == ["writing_style"] + + +@pytest.mark.asyncio +async def test_load_prompt_context_returns_standardized_bundle(db_session, monkeypatch): + user = User( + id=uuid.uuid4(), + username=f"ctx_{uuid.uuid4().hex[:6]}", + email=f"ctx_{uuid.uuid4().hex[:6]}@test.com", + name="Context User", + password_hash=hash_password("password123"), + ) + db_session.add(user) + db_session.add(AppConfig(key="ai_name", value="Kami")) + await db_session.commit() + + async def _fake_build_memory_context(*_args, **_kwargs): + return [ + {"key": "preferred_ai_name", "value": "Kami", "confidence": 0.95, "source": "conversation"}, + {"key": "current_focus", "value": "memory cleanup", "confidence": 0.8, "memory_kind": "project_state", "source": "conversation"}, + ] + + async def _fake_get_conversation_summary(*_args, **_kwargs): + return "older summary" + + async def _fake_get_notebook_summary(*_args, **_kwargs): + return {"summary_md": "notebook summary", "key_themes": ["memory"]} + + async def _fake_load_latest_portrait(*_args, **_kwargs): + return {"identity_summary": "portrait summary"} + + async def _noop(*_args, **_kwargs): + return 0 + + monkeypatch.setattr("app.agents.memory.retrieval.build_memory_context", _fake_build_memory_context) + monkeypatch.setattr("app.agents.memory.notebook.get_conversation_summary", _fake_get_conversation_summary) + monkeypatch.setattr("app.agents.memory.notebook.get_notebook_summary", _fake_get_notebook_summary) + monkeypatch.setattr("app.agents.portrait.loader.load_latest_portrait", _fake_load_latest_portrait) + monkeypatch.setattr("app.services.memory_service.MemoryService.sync_memory_doc_if_stale", _noop) + monkeypatch.setattr("app.services.memory_service.MemoryService.cleanup_runtime_memories", _noop) + + bundle = await load_prompt_context( + user_id=user.id, + query="帮我继续整理记忆系统", + db=db_session, + scene="research", + notebook_id=uuid.uuid4(), + conversation_id=uuid.uuid4(), + include_portrait=True, + ) + + assert bundle.scene == "research" + assert bundle.ai_name == "Kami" + assert bundle.conversation_summary == "older summary" + assert bundle.notebook_summary == {"summary_md": "notebook summary", "key_themes": ["memory"]} + assert bundle.portrait == {"identity_summary": "portrait summary"} + assert [memory["key"] for memory in bundle.identity_memories] == ["preferred_ai_name"] + assert [memory["key"] for memory in bundle.long_term_memories] == ["current_focus"] diff --git a/apps/api/tests/unit/test_research_task_manager.py b/apps/api/tests/unit/test_research_task_manager.py index 6685307..87f8058 100644 --- a/apps/api/tests/unit/test_research_task_manager.py +++ b/apps/api/tests/unit/test_research_task_manager.py @@ -1,4 +1,15 @@ -from app.agents.research.task_manager import collect_web_sources +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from app.agents.research import deep_research +from app.agents.research.task_manager import collect_web_sources, run_research_task +from app.models import ObservabilityRun, ResearchTask, User def test_collect_web_sources_dedupes_web_citations() -> None: @@ -33,3 +44,153 @@ def test_collect_web_sources_dedupes_web_citations() -> None: "query": "问题二", }, ] + + +@pytest.mark.asyncio +async def test_run_research_task_marks_task_and_run_error_when_graph_raises(engine, db_session, monkeypatch: pytest.MonkeyPatch) -> None: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + user = User( + id=uuid.uuid4(), + username="research-error-user", + email="research-error@example.com", + ) + db_session.add(user) + await db_session.flush() + + task = ResearchTask( + id=uuid.uuid4(), + user_id=user.id, + query="为什么会失败?", + mode="quick", + status="running", + ) + db_session.add(task) + await db_session.commit() + + class FailingGraph: + async def astream_events(self, _input_state, version: str = "v2"): + if version != "v2": + raise AssertionError("unexpected stream version") + raise RuntimeError("graph exploded") + yield # pragma: no cover + + monkeypatch.setattr("app.agents.research.task_manager.AsyncSessionLocal", session_factory) + monkeypatch.setattr("app.providers.llm.get_client", lambda: object()) + monkeypatch.setattr("app.agents.research.deep_research.create_research_graph", lambda **kwargs: FailingGraph()) + + await run_research_task( + task_id=str(task.id), + query=task.query, + notebook_id=None, + conversation_id=None, + user_id=str(user.id), + mode="quick", + model="gpt-test", + tavily_api_key=None, + user_memories=[], + ) + + async with session_factory() as check_session: + persisted_task = await check_session.get(ResearchTask, task.id) + assert persisted_task is not None + assert persisted_task.status == "error" + assert persisted_task.error_message is not None + assert "graph exploded" in persisted_task.error_message + + persisted_run = await check_session.scalar( + select(ObservabilityRun) + .where(ObservabilityRun.task_id == task.id) + .order_by(ObservabilityRun.started_at.desc()) + ) + assert persisted_run is not None + assert persisted_run.status == "failed" + assert persisted_run.error_message is not None + assert "graph exploded" in persisted_run.error_message + + +@pytest.mark.asyncio +async def test_create_research_graph_parallel_search_nodes_use_isolated_sessions(engine, db_session, monkeypatch: pytest.MonkeyPatch) -> None: + session_factory = async_sessionmaker(engine, expire_on_commit=False) + seen_session_ids: list[int] = [] + active_calls = 0 + max_parallelism = 0 + lock = asyncio.Lock() + + async def fake_plan(*args, **kwargs) -> dict: + return { + "title": "并行研究", + "research_goal": "验证 search node 会话隔离", + "evaluation_criteria": [], + "search_matrix": { + "concept": ["问题一", "问题二"], + "latest": [], + "evidence": [], + "controversy": [], + }, + } + + async def fake_research_one(*, query: str, dimension: str, db, **kwargs): + nonlocal active_calls, max_parallelism + async with lock: + active_calls += 1 + max_parallelism = max(max_parallelism, active_calls) + seen_session_ids.append(id(db.sync_session)) + await asyncio.sleep(0.01) + async with lock: + active_calls -= 1 + return deep_research.Learning( + sub_question=query, + content=f"{dimension}:{query}", + dimension=dimension, + ) + + async def fake_synthesize_report(*args, **kwargs): + yield "报告正文" + + async def fake_generate_deliverable(*args, **kwargs) -> dict: + return { + "title": "交付物", + "summary": "摘要", + "next_questions": [], + "citation_table": [], + "citation_count": 0, + } + + monkeypatch.setattr(deep_research, "_plan", fake_plan) + monkeypatch.setattr(deep_research, "_research_one", fake_research_one) + monkeypatch.setattr(deep_research, "_synthesize_report", fake_synthesize_report) + monkeypatch.setattr(deep_research, "_generate_deliverable", fake_generate_deliverable) + + graph = deep_research.create_research_graph( + db=db_session, + client=object(), # type: ignore[arg-type] + tavily_api_key=None, + db_session_factory=session_factory, + ) + + input_state = { + "query": "测试并行 search node", + "notebook_id": None, + "user_id": str(uuid.uuid4()), + "model": "gpt-test", + "tavily_api_key": None, + "user_memories": [], + "mode": "quick", + "clarification_context": None, + "report_title": "", + "research_goal": "", + "evaluation_criteria": [], + "search_matrix": {}, + "learnings": [], + "full_report": "", + "deliverable": None, + } + + events = [event async for event in graph.astream_events(input_state, version="v2")] + + assert any(event["event"] == "on_custom_event" and event["name"] == "learning" for event in events) + assert max_parallelism >= 2 + assert len(seen_session_ids) == 2 + assert len(set(seen_session_ids)) == 2 + assert id(db_session.sync_session) not in seen_session_ids diff --git a/apps/api/tests/unit/test_runtime_config_reload.py b/apps/api/tests/unit/test_runtime_config_reload.py new file mode 100644 index 0000000..fc4e412 --- /dev/null +++ b/apps/api/tests/unit/test_runtime_config_reload.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import select + +from app.config import settings +from app.domains.config.router import get_config, update_config +from app.domains.config.schemas import ConfigPatchRequest +from app.domains.setup.router import load_settings_from_db +from app.models import AppConfig +from app.providers import storage as storage_provider + + +@pytest.mark.asyncio +async def test_load_settings_from_db_resets_storage_singleton_and_prefers_canonical_region( + db_session, + monkeypatch, +) -> None: + monkeypatch.setattr(settings, "storage_backend", "local") + monkeypatch.setattr(settings, "storage_s3_region", "us-east-1") + + storage_provider.reset_storage_instance() + original_storage = storage_provider.storage() + sentinel_storage = object() + monkeypatch.setattr(storage_provider, "get_storage_provider", lambda: sentinel_storage) + + db_session.add_all( + [ + AppConfig(key="storage_backend", value="minio"), + AppConfig(key="storage_region", value="legacy-region"), + AppConfig(key="storage_s3_region", value="canonical-region"), + ] + ) + await db_session.commit() + + await load_settings_from_db(db_session) + + assert settings.storage_backend == "minio" + assert settings.storage_s3_region == "canonical-region" + assert storage_provider.storage() is sentinel_storage + assert storage_provider.storage() is not original_storage + + storage_provider.reset_storage_instance() + + +@pytest.mark.asyncio +async def test_update_config_maps_legacy_storage_region_to_canonical_key( + db_session, + monkeypatch, +) -> None: + monkeypatch.setattr(settings, "storage_backend", "local") + monkeypatch.setattr(settings, "storage_s3_region", "us-east-1") + + storage_provider.reset_storage_instance() + original_storage = storage_provider.storage() + sentinel_storage = object() + monkeypatch.setattr(storage_provider, "get_storage_provider", lambda: sentinel_storage) + + await update_config( + ConfigPatchRequest( + data={ + "storage_backend": "minio", + "storage_region": "ap-southeast-1", + } + ), + MagicMock(), + db_session, + ) + + rows = ( + await db_session.execute( + select(AppConfig).where( + AppConfig.key.in_(("storage_backend", "storage_region", "storage_s3_region")) + ) + ) + ).scalars().all() + row_map = {row.key: row.value for row in rows} + + assert row_map["storage_backend"] == "minio" + assert row_map["storage_s3_region"] == "ap-southeast-1" + assert "storage_region" not in row_map + assert settings.storage_backend == "minio" + assert settings.storage_s3_region == "ap-southeast-1" + assert storage_provider.storage() is sentinel_storage + assert storage_provider.storage() is not original_storage + + resp = await get_config(MagicMock(), db_session) + + assert resp.data is not None + assert resp.data.data["storage_region"] == "ap-southeast-1" + + storage_provider.reset_storage_instance() diff --git a/apps/api/tests/unit/test_runtime_policy.py b/apps/api/tests/unit/test_runtime_policy.py index f4fdbf6..907d229 100644 --- a/apps/api/tests/unit/test_runtime_policy.py +++ b/apps/api/tests/unit/test_runtime_policy.py @@ -10,8 +10,8 @@ def test_context_budget_for_scene_uses_review_budget() -> None: assert context_budget_for_scene("review") == 3500 -def test_context_budget_for_scene_falls_back_to_research() -> None: - assert context_budget_for_scene("unknown_scene") == 8000 +def test_context_budget_for_scene_falls_back_to_chat() -> None: + assert context_budget_for_scene("unknown_scene") == 5000 def test_build_clarification_prompt_varies_by_scene() -> None: diff --git a/apps/api/tests/unit/test_setup_router.py b/apps/api/tests/unit/test_setup_router.py new file mode 100644 index 0000000..234e104 --- /dev/null +++ b/apps/api/tests/unit/test_setup_router.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from app.domains.setup.router import setup_test_llm +from app.domains.setup.schemas import SetupTestLlmRequest + + +@pytest.mark.asyncio +async def test_setup_test_llm_delegates_to_config_service(monkeypatch) -> None: + service_call = AsyncMock(return_value={"ok": True, "message": "pong"}) + monkeypatch.setattr( + "app.domains.setup.router.ConfigService.test_llm_connection", + service_call, + ) + + response = await setup_test_llm( + SetupTestLlmRequest( + api_key="sk-live", + base_url="https://example.test/v1", + model="gpt-4o-mini", + llm_provider="openai", + ) + ) + + assert response.code == 0 + assert response.data is not None + assert response.data.ok is True + assert response.data.message == "pong" + service_call.assert_awaited_once_with( + api_key="sk-live", + base_url="https://example.test/v1", + model="gpt-4o-mini", + llm_provider="openai", + ) diff --git a/apps/api/tests/unit/test_skill_registry_guides.py b/apps/api/tests/unit/test_skill_registry_guides.py index e9d57e8..7e41f15 100644 --- a/apps/api/tests/unit/test_skill_registry_guides.py +++ b/apps/api/tests/unit/test_skill_registry_guides.py @@ -4,6 +4,7 @@ import pytest +from app.agents.memory import build_prompt_context_bundle from app.agents.writing.composer import build_system_prompt from app.skills.base import MarkdownSkill, SkillBase, SkillMeta from app.skills.builtin.read_skill_guide import ReadSkillGuideSkill @@ -87,22 +88,12 @@ async def test_read_skill_guide_returns_markdown_body(tmp_path: Path, monkeypatc @pytest.mark.asyncio async def test_build_system_prompt_uses_guide_manifest_instead_of_body( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: tool_skill = _DummyToolSkill() guide_skill = MarkdownSkill.from_file(_write_skill_file(tmp_path)) - monkeypatch.setattr("app.agents.memory.get_memory_doc_content", lambda: "") - - async def _empty_diary(limit: int = 3) -> str: - return "" - - monkeypatch.setattr("app.agents.memory.get_recent_diary_notes", _empty_diary) - prompt = await build_system_prompt( - user_memories=[], - notebook_summary=None, - db=None, + build_prompt_context_bundle(scene="chat"), active_skills=[tool_skill, guide_skill], ) @@ -117,29 +108,23 @@ async def _empty_diary(limit: int = 3) -> str: assert "LyraNote 风格执行纪律" in prompt assert "简单问题直接回答" in prompt assert "不要把结构化 UI payload、原始 JSON 或工具内部格式直接暴露给用户" in prompt + assert "## 额外指导" not in prompt @pytest.mark.asyncio async def test_build_system_prompt_groups_user_memories_by_kind( - monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr("app.agents.memory.get_memory_doc_content", lambda: "") - - async def _empty_diary(limit: int = 3) -> str: - return "" - - monkeypatch.setattr("app.agents.memory.get_recent_diary_notes", _empty_diary) - prompt = await build_system_prompt( - user_memories=[ - {"key": "preferred_ai_name", "value": "Lyra", "confidence": 0.9, "memory_type": "preference", "memory_kind": "preference"}, - {"key": "writing_style", "value": "简洁", "confidence": 0.9, "memory_type": "preference", "memory_kind": "preference"}, - {"key": "professional_background", "value": "AI infra engineer", "confidence": 0.8, "memory_type": "fact", "memory_kind": "profile"}, - {"key": "current_research_topic", "value": "Agent runtime", "confidence": 0.8, "memory_type": "fact", "memory_kind": "project_state"}, - {"key": "project_docs_url", "value": "https://example.com/spec", "confidence": 0.8, "memory_type": "fact", "memory_kind": "reference"}, - ], - notebook_summary=None, - db=None, + build_prompt_context_bundle( + scene="chat", + user_memories=[ + {"key": "preferred_ai_name", "value": "Lyra", "confidence": 0.9, "memory_type": "preference", "memory_kind": "preference"}, + {"key": "writing_style", "value": "简洁", "confidence": 0.9, "memory_type": "preference", "memory_kind": "preference"}, + {"key": "professional_background", "value": "AI infra engineer", "confidence": 0.8, "memory_type": "fact", "memory_kind": "profile"}, + {"key": "current_research_topic", "value": "Agent runtime", "confidence": 0.8, "memory_type": "fact", "memory_kind": "project_state"}, + {"key": "project_docs_url", "value": "https://example.com/spec", "confidence": 0.8, "memory_type": "fact", "memory_kind": "reference"}, + ], + ), active_skills=[], ) diff --git a/apps/api/tests/unit/test_source_service_dispatch.py b/apps/api/tests/unit/test_source_service_dispatch.py new file mode 100644 index 0000000..2000026 --- /dev/null +++ b/apps/api/tests/unit/test_source_service_dispatch.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.exceptions import NotFoundError +from app.models import ObservabilityRun, Source +from app.services.desktop_runtime_service import desktop_state_store +from app.services.source_service import SourceService + + +@pytest.mark.asyncio +async def test_upload_source_dispatches_ingestion_after_commit(monkeypatch) -> None: + added: list[object] = [] + + async def flush() -> None: + for obj in added: + if isinstance(obj, Source) and getattr(obj, "id", None) is None: + setattr(obj, "id", uuid.uuid4()) + if isinstance(obj, ObservabilityRun) and getattr(obj, "id", None) is None: + setattr(obj, "id", uuid.uuid4()) + + db = SimpleNamespace( + add=Mock(side_effect=added.append), + flush=AsyncMock(side_effect=flush), + refresh=AsyncMock(return_value=None), + commit=AsyncMock(return_value=None), + sync_session=SimpleNamespace(info={}), + ) + service = SourceService(db, uuid.uuid4()) + notebook_id = uuid.uuid4() + monkeypatch.setattr(service, "_assert_notebook_owner", AsyncMock(return_value=None)) + + storage = SimpleNamespace(upload=AsyncMock(return_value=None)) + monkeypatch.setattr("app.providers.storage.storage", lambda: storage) + + delayed: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + "app.workers.tasks.ingest_source.delay", + lambda source_id, **kwargs: delayed.append((source_id, kwargs)), + ) + + source = await service.upload_source(notebook_id, "demo.pdf", b"pdf-bytes") + + assert source.status == "pending" + assert delayed == [] + + callbacks = db.sync_session.info.get("_after_commit_callbacks", []) + assert len(callbacks) == 1 + callbacks[0]() + + assert delayed[0][0] == str(source.id) + assert delayed[0][1]["trace_id"] + assert delayed[0][1]["run_id"] + storage.upload.assert_awaited_once() + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_import_source_url_dispatches_ingestion_after_commit(monkeypatch) -> None: + added: list[object] = [] + + async def flush() -> None: + for obj in added: + if isinstance(obj, Source) and getattr(obj, "id", None) is None: + setattr(obj, "id", uuid.uuid4()) + if isinstance(obj, ObservabilityRun) and getattr(obj, "id", None) is None: + setattr(obj, "id", uuid.uuid4()) + + db = SimpleNamespace( + add=Mock(side_effect=added.append), + flush=AsyncMock(side_effect=flush), + refresh=AsyncMock(return_value=None), + commit=AsyncMock(return_value=None), + sync_session=SimpleNamespace(info={}), + ) + service = SourceService(db, uuid.uuid4()) + service._assert_notebook_owner = AsyncMock(return_value=None) # type: ignore[method-assign] + + delayed: list[tuple[str, dict[str, object]]] = [] + monkeypatch.setattr( + "app.workers.tasks.ingest_source.delay", + lambda source_id, **kwargs: delayed.append((source_id, kwargs)), + ) + + source = await service.import_source_url( + uuid.uuid4(), + "https://example.com/post", + "Example Post", + ) + + assert source.status == "pending" + assert delayed == [] + + callbacks = db.sync_session.info.get("_after_commit_callbacks", []) + assert len(callbacks) == 1 + callbacks[0]() + assert delayed[0][0] == str(source.id) + assert delayed[0][1]["trace_id"] + assert delayed[0][1]["run_id"] + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_import_global_source_path_reads_file_and_delegates( + tmp_path, + monkeypatch, +) -> None: + service = SourceService(SimpleNamespace(), uuid.uuid4()) + file_path = tmp_path / "notes.md" + file_path.write_text("# Desktop import", encoding="utf-8") + + upload_mock = AsyncMock(return_value="source") + monkeypatch.setattr(service, "upload_global_source", upload_mock) + + result = await service.import_global_source_path(str(file_path)) + + assert result == "source" + upload_mock.assert_awaited_once_with("notes.md", b"# Desktop import") + + +@pytest.mark.asyncio +async def test_import_global_source_path_rejects_missing_files() -> None: + service = SourceService(SimpleNamespace(), uuid.uuid4()) + + with pytest.raises(NotFoundError, match="文件不存在"): + await service.import_global_source_path("/tmp/lyranote-missing-file.pdf") + + +@pytest.mark.asyncio +async def test_upload_source_uses_desktop_job_queue_when_runtime_profile_is_desktop( + monkeypatch, +) -> None: + added: list[object] = [] + + async def flush() -> None: + for obj in added: + if isinstance(obj, Source) and getattr(obj, "id", None) is None: + setattr(obj, "id", uuid.uuid4()) + if isinstance(obj, ObservabilityRun) and getattr(obj, "id", None) is None: + setattr(obj, "id", uuid.uuid4()) + + db = SimpleNamespace( + add=Mock(side_effect=added.append), + flush=AsyncMock(side_effect=flush), + refresh=AsyncMock(return_value=None), + commit=AsyncMock(return_value=None), + sync_session=SimpleNamespace(info={}), + ) + service = SourceService(db, uuid.uuid4()) + notebook_id = uuid.uuid4() + monkeypatch.setattr(service, "_assert_notebook_owner", AsyncMock(return_value=None)) + + storage = SimpleNamespace(upload=AsyncMock(return_value=None)) + monkeypatch.setattr("app.providers.storage.storage", lambda: storage) + monkeypatch.setattr("app.config.settings.runtime_profile", "desktop") + + queued: list[dict[str, object]] = [] + monkeypatch.setattr( + "app.services.desktop_runtime_service.desktop_job_manager.enqueue_source_ingest", + lambda **payload: queued.append(payload) or "job-1", + ) + monkeypatch.setattr("app.workers.tasks.ingest_source.delay", lambda source_id: (_ for _ in ()).throw(AssertionError("celery should not run"))) + + source = await service.upload_source(notebook_id, "desktop.pdf", b"pdf-bytes") + + callbacks = db.sync_session.info.get("_after_commit_callbacks", []) + assert len(callbacks) == 1 + callbacks[0]() + + assert source.status == "pending" + assert queued[0]["trace_id"] + assert queued[0]["run_id"] + assert queued == [ + { + "user_id": str(service.user_id), + "source_id": str(source.id), + "trace_id": queued[0]["trace_id"], + "run_id": queued[0]["run_id"], + "kind": "import", + "label": "索引资料:desktop.pdf", + "chunk_size": None, + "chunk_overlap": None, + "splitter_type": None, + "separators": None, + "min_chunk_size": None, + } + ] + + +@pytest.mark.asyncio +async def test_import_global_source_path_reuses_existing_source_for_duplicate_content( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setattr("app.config.settings.runtime_profile", "desktop") + monkeypatch.setattr( + "app.config.settings.desktop_state_dir_override", + str(tmp_path / "desktop-state"), + ) + + service = SourceService(SimpleNamespace(), uuid.uuid4()) + original = tmp_path / "paper-a.md" + duplicate = tmp_path / "paper-b.md" + original.write_text("same-content", encoding="utf-8") + duplicate.write_text("same-content", encoding="utf-8") + + desktop_state_store.record_import( + user_id=str(service.user_id), + path=str(original), + source_id=str(uuid.uuid4()), + title="paper-a.md", + sha256="digest-1", + ) + + existing = SimpleNamespace(id=uuid.uuid4(), title="paper-a.md") + monkeypatch.setattr(service, "_get_owned_source", AsyncMock(return_value=existing)) + upload_mock = AsyncMock() + monkeypatch.setattr(service, "upload_global_source", upload_mock) + + result = await service.import_global_source_path(str(duplicate), sha256="digest-1") + + assert result is existing + upload_mock.assert_not_awaited() diff --git a/apps/api/tests/unit/test_source_service_web_import.py b/apps/api/tests/unit/test_source_service_web_import.py index ce85493..d379f46 100644 --- a/apps/api/tests/unit/test_source_service_web_import.py +++ b/apps/api/tests/unit/test_source_service_web_import.py @@ -34,14 +34,20 @@ async def flush() -> None: add=Mock(side_effect=added.append), flush=AsyncMock(side_effect=flush), refresh=AsyncMock(return_value=None), + commit=AsyncMock(return_value=None), execute=AsyncMock(return_value=_ScalarResult(["https://example.com/page?utm_source=test"])), + sync_session=SimpleNamespace(info={}), ) service = SourceService(db, uuid.uuid4()) notebook_id = uuid.uuid4() monkeypatch.setattr(service, "_assert_notebook_owner", AsyncMock(return_value=None)) - delayed: list[str] = [] - monkeypatch.setattr("app.workers.tasks.ingest_source.delay", lambda source_id: delayed.append(source_id)) + delayed: list[tuple[str, str | None, str | None]] = [] + + def fake_delay(source_id: str, *, trace_id: str | None = None, run_id: str | None = None) -> None: + delayed.append((source_id, trace_id, run_id)) + + monkeypatch.setattr("app.workers.tasks.ingest_source.delay", fake_delay) result = await service.import_web_sources( [ @@ -56,9 +62,18 @@ async def flush() -> None: assert result.notebook_id == notebook_id assert result.created_count == 1 assert result.skipped_count == 3 - assert len(delayed) == 1 + assert delayed == [] + callbacks = db.sync_session.info.get("_after_commit_callbacks", []) + assert len(callbacks) == 1 + callbacks[0]() created_source = next(obj for obj in added if isinstance(obj, Source)) + assert len(delayed) == 1 + assert delayed[0][0] == str(created_source.id) + assert delayed[0][1] is not None + assert delayed[0][2] is not None + db.commit.assert_awaited_once() + assert created_source.url == "https://example.com/new" @@ -75,14 +90,20 @@ async def flush() -> None: add=Mock(side_effect=added.append), flush=AsyncMock(side_effect=flush), refresh=AsyncMock(return_value=None), + commit=AsyncMock(return_value=None), execute=AsyncMock(return_value=_ScalarResult([])), + sync_session=SimpleNamespace(info={}), ) service = SourceService(db, uuid.uuid4()) global_notebook = SimpleNamespace(id=uuid.uuid4()) monkeypatch.setattr(service, "_get_or_create_global_notebook", AsyncMock(return_value=global_notebook)) - delayed: list[str] = [] - monkeypatch.setattr("app.workers.tasks.ingest_source.delay", lambda source_id: delayed.append(source_id)) + delayed: list[tuple[str, str | None, str | None]] = [] + + def fake_delay(source_id: str, *, trace_id: str | None = None, run_id: str | None = None) -> None: + delayed.append((source_id, trace_id, run_id)) + + monkeypatch.setattr("app.workers.tasks.ingest_source.delay", fake_delay) result = await service.import_web_sources( [{"title": "Global Source", "url": "https://global.example.com/article"}], @@ -91,4 +112,14 @@ async def flush() -> None: assert result.notebook_id == global_notebook.id assert result.created_count == 1 assert result.skipped_count == 0 + assert delayed == [] + + callbacks = db.sync_session.info.get("_after_commit_callbacks", []) + assert len(callbacks) == 1 + callbacks[0]() assert len(delayed) == 1 + created_source = next(obj for obj in added if isinstance(obj, Source)) + assert delayed[0][0] == str(created_source.id) + assert delayed[0][1] is not None + assert delayed[0][2] is not None + db.commit.assert_awaited_once() diff --git a/apps/api/tests/unit/test_suggestion_service.py b/apps/api/tests/unit/test_suggestion_service.py index d163189..fc0cf88 100644 --- a/apps/api/tests/unit/test_suggestion_service.py +++ b/apps/api/tests/unit/test_suggestion_service.py @@ -61,17 +61,39 @@ def _mock_utility_client(content: str): class TestSuggestionService: - async def test_get_user_suggestions_returns_fallback_without_generating(self): + async def test_get_user_suggestions_warms_cache_on_miss(self): user_id = uuid.uuid4() redis = _FakeRedis() + db = _FakeDB([ + _FakeResult([("论文A", "摘要A")]), + _FakeResult([("对话A",)]), + ]) + utility_client, create_mock = _mock_utility_client('["问题1","问题2","问题3","问题4"]') + service = SuggestionService(db, redis_client=redis, utility_client=utility_client) + + suggestions = await service.get_user_suggestions(user_id) + cached = await service._read_cached_payload(str(user_id)) + + assert suggestions == ["问题1", "问题2", "问题3", "问题4"] + assert cached is not None + assert cached["suggestions"] == ["问题1", "问题2", "问题3", "问题4"] + create_mock.assert_awaited_once() + + async def test_get_user_suggestions_returns_fallback_when_miss_has_no_context(self): + user_id = uuid.uuid4() + redis = _FakeRedis() + db = _FakeDB([ + _FakeResult([]), + _FakeResult([]), + ]) utility_client = SimpleNamespace( chat=SimpleNamespace( completions=SimpleNamespace( - create=AsyncMock(side_effect=AssertionError("LLM must not be called in request path")) + create=AsyncMock(side_effect=AssertionError("LLM should not run without context")) ) ) ) - service = SuggestionService(_FakeDB([]), redis_client=redis, utility_client=utility_client) + service = SuggestionService(db, redis_client=redis, utility_client=utility_client) suggestions = await service.get_user_suggestions(user_id) diff --git a/apps/api/tests/unit/test_upload_router.py b/apps/api/tests/unit/test_upload_router.py new file mode 100644 index 0000000..7f2d043 --- /dev/null +++ b/apps/api/tests/unit/test_upload_router.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi.responses import Response + +from app.domains.upload.router import get_temp_file, upload_temp_file + + +@pytest.mark.asyncio +async def test_upload_temp_file_delegates_to_service(monkeypatch) -> None: + service_call = AsyncMock( + return_value={ + "id": "file-1", + "storage_key": "temp/user/file-1.txt", + "filename": "demo.txt", + "content_type": "text/plain", + "size": 4, + } + ) + monkeypatch.setattr( + "app.domains.upload.router.UploadService.upload_temp_file", + service_call, + ) + + file = SimpleNamespace() + current_user = SimpleNamespace(id=uuid4()) + + response = await upload_temp_file(file=file, current_user=current_user) + + assert response.code == 0 + assert response.data is not None + assert response.data["id"] == "file-1" + service_call.assert_awaited_once_with(file, str(current_user.id)) + + +@pytest.mark.asyncio +async def test_get_temp_file_delegates_to_service(monkeypatch) -> None: + service_call = AsyncMock(return_value=Response(content=b"demo", media_type="text/plain")) + monkeypatch.setattr( + "app.domains.upload.router.UploadService.get_temp_file", + service_call, + ) + + current_user = SimpleNamespace(id=uuid4()) + response = await get_temp_file(file_id="file-1", current_user=current_user) + + assert response.status_code == 200 + assert response.body == b"demo" + service_call.assert_awaited_once_with("file-1", str(current_user.id)) diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/desktop/.vscode/extensions.json b/apps/desktop/.vscode/extensions.json new file mode 100644 index 0000000..24d7cc6 --- /dev/null +++ b/apps/desktop/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..3153e69 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,83 @@ +# LyraNote Desktop + +LyraNote Desktop is the Tauri + React desktop shell. It bundles the Vite frontend and a local API sidecar built from `apps/api/app/desktop_main.py`. + +## Local Development + +```bash +cd apps/desktop +pnpm dev +pnpm tauri dev +``` + +The Tauri dev config starts Vite on `http://localhost:1420`. + +## Local macOS Packaging + +Build the bundled API sidecar first, then build the Tauri app: + +```bash +cd apps/api +./.venv/bin/python -m pip install -r requirements.txt -r requirements-dev.txt +./.venv/bin/python scripts/build_desktop_sidecar.py + +cd ../desktop +pnpm tauri build +``` + +The sidecar script writes a target-triple wrapper to `src-tauri/binaries/` and a PyInstaller +onedir runtime to `src-tauri/binaries/lyranote-api-desktop-runtime/`. Tauri stores the wrapper in +`Contents/MacOS` and the runtime in `Contents/Resources` so the packaged app does not depend on +PyInstaller onefile extraction at startup. + +Release bundles are written under: + +```bash +apps/desktop/src-tauri/target/release/bundle/ +``` + +Updater artifacts require the Tauri signing private key during build: + +```bash +export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/lyranote-updater.key)" +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="" +cd apps/desktop +pnpm tauri build +``` + +The public key is committed in `src-tauri/tauri.conf.json`. The matching private key generated for this setup is stored at `~/.tauri/lyranote-updater.key` on this machine. Keep it secret and backed up; losing it means already-installed apps cannot accept future updates signed by a different key. + +## GitHub Release Packaging + +Pushing a SemVer tag triggers `.github/workflows/release.yml`: + +```bash +git tag v0.4.0 +git push origin v0.4.0 +``` + +The workflow: + +1. Creates or refreshes a draft GitHub Release. +2. Builds macOS Apple Silicon (`aarch64-apple-darwin`) and Intel (`x86_64-apple-darwin`) desktop bundles. +3. Uploads `.dmg`, updater artifacts, `.sig` files, and `latest.json`. +4. Publishes `@lyranote/cli` to npm. +5. Publishes the draft Release only after all required jobs pass. + +Required GitHub Secrets: + +- `TAURI_SIGNING_PRIVATE_KEY`: content of the Tauri updater private key. +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`: empty for the current generated key, or the password if the key is regenerated with one. +- `NPM_TOKEN`: optional; only required if the same release workflow should also publish `@lyranote/cli` to npm. + +## Installed App Updates + +The app checks stable updates from: + +```text +https://github.com/LinMoQC/LyraNote/releases/latest/download/latest.json +``` + +Users can open Settings -> Security -> Desktop Updates, check for a new version, download and install it, then relaunch the app to finish the update. + +This first release path does not include Apple Developer ID signing or notarization. macOS may still require the user to trust the app manually after installation. diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js new file mode 100644 index 0000000..e38f93a --- /dev/null +++ b/apps/desktop/eslint.config.js @@ -0,0 +1,83 @@ +import tsParser from "@typescript-eslint/parser" +import tsPlugin from "@typescript-eslint/eslint-plugin" +import reactHooks from "eslint-plugin-react-hooks" + +const noAiSdkRule = [ + "error", + { + patterns: [ + { + group: ["openai", "@anthropic-ai/*", "litellm"], + message: "禁止在前端导入 AI SDK。AI 调用只在后端 providers/ 层进行。", + }, + ], + paths: [ + { + name: "axios", + message: "禁止直接导入 axios。请使用 service 层和共享 api-client。", + }, + ], + }, +] + +export default [ + { + ignores: ["dist/**", "node_modules/**", "src-tauri/**"], + }, + { + files: ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + "react-hooks": reactHooks, + }, + rules: { + "no-console": ["warn", { allow: ["warn", "error"] }], + "no-restricted-imports": noAiSdkRule, + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + }, + { + files: ["src/features/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["openai", "@anthropic-ai/*", "litellm"], + message: "禁止在前端导入 AI SDK。", + }, + ], + paths: [ + { + name: "axios", + message: "禁止在 features/ 或 components/ 中直接导入 axios。请调用 src/services/ 层的函数。", + }, + ], + }, + ], + }, + }, + { + files: ["src/lib/http.ts"], + rules: { + "no-restricted-imports": "off", + }, + }, +] diff --git a/apps/desktop/index.html b/apps/desktop/index.html index fa1b663..ff93803 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -1,14 +1,12 @@ - + + - LyraNote - + Tauri + React + Typescript +
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a91d30d..0541389 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,42 +1,82 @@ { "name": "@lyranote/desktop", - "version": "0.1.0", "private": true, + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", + "lint": "eslint src tests --ext .ts,.tsx", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri", - "lint": "eslint src --ext ts,tsx", - "typecheck": "tsc --noEmit" + "tauri": "tauri" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@lyranote/api-client": "workspace:*", "@lyranote/types": "workspace:*", - "@tauri-apps/api": "^2.0.0", - "@tauri-apps/plugin-notification": "^2.0.0", - "@tauri-apps/plugin-store": "^2.0.0", - "@tauri-apps/plugin-updater": "^2.0.0", - "@tanstack/react-query": "^5.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-router-dom": "^6.0.0", - "zustand": "^5.0.0", - "axios": "^1.6.0", - "clsx": "^2.0.0", - "tailwind-merge": "^2.0.0", - "lucide-react": "^0.400.0" + "@lyranote/ui": "workspace:*", + "@tanstack/react-query": "^5.66.0", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2", + "@tauri-apps/plugin-updater": "^2", + "@tiptap/extension-bubble-menu": "^2.11.5", + "@tiptap/extension-character-count": "^2", + "@tiptap/extension-code-block-lowlight": "^2", + "@tiptap/extension-highlight": "^2.11.5", + "@tiptap/extension-image": "^2", + "@tiptap/extension-link": "^2.11.5", + "@tiptap/extension-placeholder": "^2.11.5", + "@tiptap/extension-task-item": "^2", + "@tiptap/extension-task-list": "^2", + "@tiptap/extension-typography": "^2", + "@tiptap/extension-underline": "^2.11.5", + "@tiptap/pm": "^2.27.2", + "@tiptap/react": "^2.11.5", + "@tiptap/starter-kit": "^2.11.5", + "axios": "^1.15.0", + "clsx": "^2.1.1", + "framer-motion": "^12.35.0", + "katex": "^0.16.40", + "lowlight": "^3.3.0", + "lucide-react": "^1.8.0", + "mermaid": "^11.13.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-drawio": "^1.0.7", + "react-force-graph-2d": "^1.29.1", + "react-markdown": "^10.1.0", + "react-wordcloud": "^1.2.7", + "recharts": "^3.8.0", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^2.5.5", + "zustand": "^5.0.12" }, "devDependencies": { - "@tauri-apps/cli": "^2.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.0.0", - "autoprefixer": "^10.0.0", - "postcss": "^8.0.0", - "tailwindcss": "^3.4.0", - "typescript": "^5.0.0", - "vite": "^5.0.0" + "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/node": "^22.10.2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@typescript-eslint/eslint-plugin": "^8.43.0", + "@typescript-eslint/parser": "^8.43.0", + "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^3.2.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.35.0", + "eslint-plugin-react-hooks": "^5.2.0", + "jsdom": "^26.0.0", + "postcss": "^8.4.49", + "tailwindcss": "^3", + "typescript": "~5.8.3", + "vite": "^7.0.4", + "vitest": "^3.2.4" } } diff --git a/apps/desktop/postcss.config.js b/apps/desktop/postcss.config.js index 2aa7205..2e7af2b 100644 --- a/apps/desktop/postcss.config.js +++ b/apps/desktop/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -}; +} diff --git a/apps/desktop/public/bot_avatar.png b/apps/desktop/public/bot_avatar.png new file mode 100644 index 0000000..6c31c73 Binary files /dev/null and b/apps/desktop/public/bot_avatar.png differ diff --git a/apps/desktop/public/lyra.png b/apps/desktop/public/lyra.png new file mode 100644 index 0000000..bceba09 Binary files /dev/null and b/apps/desktop/public/lyra.png differ diff --git a/apps/desktop/public/tauri.svg b/apps/desktop/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/apps/desktop/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/desktop/public/vite.svg b/apps/desktop/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/desktop/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src-tauri/.gitignore b/apps/desktop/src-tauri/.gitignore new file mode 100644 index 0000000..ef24bb7 --- /dev/null +++ b/apps/desktop/src-tauri/.gitignore @@ -0,0 +1,12 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas + +# Generated placeholder / built desktop sidecar binaries +/binaries/lyranote-api-desktop-* +/binaries/lyranote-api-desktop-runtime/ +!/binaries/README.md diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..93e143f --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.lock @@ -0,0 +1,6057 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.14.0", + "selectors 0.24.0", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lyranote-desktop" +version = "0.1.0" +dependencies = [ + "notify", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-global-shortcut", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", + "window-vibrancy", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-rust" +version = "4.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8146c105ae33d744e2d645f684d063b01176a99daf5986556266777b428816" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.38.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall 0.5.18", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a07180e..fc7f16f 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,33 +1,31 @@ [package] name = "lyranote-desktop" version = "0.1.0" -description = "LyraNote Desktop Client" -authors = ["LyraNote Team"] -license = "" -repository = "" -default-run = "lyranote-desktop" +description = "A Tauri App" +authors = ["you"] edition = "2021" -rust-version = "1.77.2" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 name = "lyranote_desktop_lib" crate-type = ["staticlib", "cdylib", "rlib"] -[[bin]] -name = "lyranote-desktop" -path = "src/main.rs" - [build-dependencies] -tauri-build = { version = "2.0", features = [] } +tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2.0", features = ["tray-icon"] } -tauri-plugin-notification = "2.0" -tauri-plugin-store = "2.0" -tauri-plugin-updater = "2.0" +tauri = { version = "2", features = ["macos-private-api"] } +tauri-plugin-opener = "2" +tauri-plugin-notification = "2" +tauri-plugin-process = "2" +tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" - -[features] -default = ["custom-protocol"] -custom-protocol = ["tauri/custom-protocol"] +window-vibrancy = "0.6" +notify = "8.2.0" +sha2 = "0.10" +tauri-plugin-global-shortcut = "2" diff --git a/apps/desktop/src-tauri/binaries/README.md b/apps/desktop/src-tauri/binaries/README.md new file mode 100644 index 0000000..7060790 --- /dev/null +++ b/apps/desktop/src-tauri/binaries/README.md @@ -0,0 +1,23 @@ +Place bundled desktop sidecar files in this directory before running `tauri build`. + +The build script generates: + +- A thin shell wrapper that follows Tauri's `externalBin` target-triple convention. +- A PyInstaller onedir runtime copied into `lyranote-api-desktop-runtime/`, which Tauri + bundles as an app resource. + +Expected wrapper filenames: + +- `lyranote-api-desktop-aarch64-apple-darwin` +- `lyranote-api-desktop-x86_64-apple-darwin` + +Expected runtime directory: + +- `lyranote-api-desktop-runtime/lyranote-api-desktop` + +You can build the current host binary with: + +```bash +cd /Users/kaihuang/Desktop/graduation-project/LyraNote/apps/api +python3 scripts/build_desktop_sidecar.py +``` diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..8d89dbb --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,42 @@ +fn main() { + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"); + let target = std::env::var("TARGET").expect("TARGET is set by cargo"); + let binaries_dir = std::path::Path::new(&manifest_dir).join("binaries"); + let sidecar_path = binaries_dir.join(format!("lyranote-api-desktop-{target}")); + let runtime_dir = binaries_dir.join("lyranote-api-desktop-runtime"); + let runtime_path = runtime_dir.join("lyranote-api-desktop"); + if !sidecar_path.exists() { + std::fs::create_dir_all(&binaries_dir).expect("failed to create Tauri binaries dir"); + std::fs::write( + &sidecar_path, + "#!/bin/sh\n\necho \"LyraNote desktop sidecar binary is missing. Run: cd apps/api && python3 scripts/build_desktop_sidecar.py\" >&2\nexit 1\n", + ) + .expect("failed to write placeholder desktop sidecar"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let permissions = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&sidecar_path, permissions) + .expect("failed to mark placeholder sidecar executable"); + } + } + if !runtime_path.exists() { + std::fs::create_dir_all(&runtime_dir).expect("failed to create desktop sidecar runtime dir"); + std::fs::write( + &runtime_path, + "#!/bin/sh\n\necho \"LyraNote desktop sidecar runtime is missing. Run: cd apps/api && python3 scripts/build_desktop_sidecar.py\" >&2\nexit 1\n", + ) + .expect("failed to write placeholder desktop sidecar runtime"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let permissions = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&runtime_path, permissions) + .expect("failed to mark placeholder sidecar runtime executable"); + } + } + tauri_build::build() +} diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..b99f358 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,14 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize", + "core:window:allow-minimize", + "core:window:allow-close" + ] +} diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..d505306 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..e687702 Binary files /dev/null and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..507e37f Binary files /dev/null and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..e3428b8 Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..bd66b9c Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..1541add Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..fc4906e Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..119803e Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..ec42a0b Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..5ac070b Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..cb49bdc Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..8dd1d1a Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..4e68035 Binary files /dev/null and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..72e006a Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..247dccb Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..55d3961 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/src/app/bootstrap.rs b/apps/desktop/src-tauri/src/app/bootstrap.rs new file mode 100644 index 0000000..afe39c3 --- /dev/null +++ b/apps/desktop/src-tauri/src/app/bootstrap.rs @@ -0,0 +1,60 @@ +use crate::{ + app::{build_app_menu, emit_shell_event, open_window, DesktopShell}, + runtime::{post_global_import, DesktopRuntime, WatchManager}, + shared::DesktopWindowKind, +}; +use tauri::{App, AppHandle, Manager, RunEvent, Runtime}; + +#[cfg(target_os = "macos")] +use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; + +pub fn setup_app(app: &mut App) -> Result<(), Box> { + let window = app.get_webview_window("main").unwrap(); + let shell = app.state::(); + let runtime = app.state::(); + let watch_manager = app.state::(); + shell.hydrate(&app.handle()); + watch_manager.hydrate(&app.handle(), runtime.inner()); + + #[cfg(target_os = "macos")] + apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, Some(12.0)) + .expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS"); + + let _ = runtime.ensure_started(&app.handle()); + if let Ok(menu) = build_app_menu(&app.handle(), shell.inner(), watch_manager.inner()) { + let _ = app.set_menu(menu); + } + emit_shell_event(&app.handle(), shell.inner()); + + Ok(()) +} + +pub fn handle_run_event(app: &AppHandle, event: RunEvent) { + match event { + RunEvent::Exit => { + let runtime = app.state::(); + let _ = runtime.stop(); + } + #[cfg(target_os = "macos")] + RunEvent::Opened { urls } => { + let runtime = app.state::(); + for url in urls { + if url.scheme() != "file" { + continue; + } + if let Ok(path) = url.to_file_path() { + let _ = post_global_import(runtime.inner(), &path.display().to_string()); + let _ = open_window( + app, + DesktopWindowKind::Main, + Some(serde_json::json!({ + "section": "knowledge", + "openedPath": path.display().to_string(), + })), + ); + } + } + } + _ => {} + } +} diff --git a/apps/desktop/src-tauri/src/app/events.rs b/apps/desktop/src-tauri/src/app/events.rs new file mode 100644 index 0000000..acb4f54 --- /dev/null +++ b/apps/desktop/src-tauri/src/app/events.rs @@ -0,0 +1,274 @@ +use crate::shared::{ + DesktopRecentItem, DesktopShellEvent, DesktopShortcutConfig, RuntimeEnvironmentProbe, + RuntimeStatus, WatcherDiagnostics, +}; +use serde_json::Value; +use std::{ + fs, + path::PathBuf, + sync::{Arc, Mutex}, +}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; + +use super::shortcuts::apply_global_shortcut; + +pub const SHELL_EVENT_NAME: &str = "desktop://shell"; + +#[derive(Clone)] +pub struct DesktopShell { + inner: Arc>, +} + +struct DesktopShellInner { + shortcut: DesktopShortcutConfig, +} + +impl Default for DesktopShell { + fn default() -> Self { + Self { + inner: Arc::new(Mutex::new(DesktopShellInner { + shortcut: DesktopShortcutConfig::default(), + })), + } + } +} + +impl DesktopShell { + pub fn hydrate(&self, app: &AppHandle) { + let mut config = load_shortcut_config(app).unwrap_or_default(); + match apply_global_shortcut(app, &config) { + Ok(supported) => { + config.supported = supported; + } + Err(error) => { + eprintln!("[desktop-shell] failed to hydrate global shortcut: {error}"); + config.supported = false; + } + } + if let Ok(mut inner) = self.inner.lock() { + inner.shortcut = config; + } + emit_shell_event(app, self); + } + + pub fn shortcut_config(&self) -> DesktopShortcutConfig { + self.inner + .lock() + .expect("desktop shell mutex poisoned") + .shortcut + .clone() + } + + pub fn update_shortcut( + &self, + app: &AppHandle, + mut config: DesktopShortcutConfig, + ) -> Result { + config.supported = apply_global_shortcut(app, &config)?; + { + let mut inner = self + .inner + .lock() + .map_err(|_| "desktop shell mutex poisoned".to_string())?; + inner.shortcut = config.clone(); + } + persist_shortcut_config(app, &config)?; + emit_shell_event(app, self); + Ok(config) + } +} + +pub fn emit_shell_event(app: &AppHandle, shell: &DesktopShell) { + let _ = app.emit( + SHELL_EVENT_NAME, + DesktopShellEvent { + shortcut: shell.shortcut_config(), + }, + ); +} + +fn shortcut_config_path(app: &AppHandle) -> Result { + let state_dir = app + .path() + .app_data_dir() + .map(|path| path.join("desktop")) + .unwrap_or_else(|_| { + PathBuf::from(format!( + "{}/.lyranote/desktop", + std::env::var("HOME").unwrap_or_default() + )) + }); + fs::create_dir_all(&state_dir) + .map_err(|error| format!("failed to create desktop state dir: {error}"))?; + Ok(state_dir.join("shell-config.json")) +} + +fn load_shortcut_config(app: &AppHandle) -> Option { + let path = shortcut_config_path(app).ok()?; + let raw = fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn persist_shortcut_config( + app: &AppHandle, + config: &DesktopShortcutConfig, +) -> Result<(), String> { + let path = shortcut_config_path(app)?; + let raw = serde_json::to_string_pretty(config) + .map_err(|error| format!("failed to serialize shortcut config: {error}"))?; + fs::write(path, raw).map_err(|error| format!("failed to persist shortcut config: {error}")) +} + +pub fn trim_recent_items(items: Vec) -> Vec { + let mut deduped = Vec::new(); + for item in items { + let exists = deduped.iter().any(|existing: &DesktopRecentItem| { + existing.path == item.path && existing.title == item.title + }); + if !exists { + deduped.push(item); + } + if deduped.len() >= 8 { + break; + } + } + deduped +} + +pub fn build_diagnostics_bundle( + status: &RuntimeStatus, + watch_folders: Value, + jobs: Value, + recent_items: Value, + log_excerpt: Vec, + generated_at: String, + environment: RuntimeEnvironmentProbe, + watcher_diagnostics: WatcherDiagnostics, +) -> Value { + serde_json::json!({ + "generated_at": generated_at, + "runtime": { + "state": status.state.clone(), + "mode": status.mode.clone(), + "pid": status.pid, + "version": status.version.clone(), + "health_url": status.health_url.clone(), + "api_base_url": status.api_base_url.clone(), + "last_error": status.last_error.clone(), + "last_exit_reason": status.last_exit_reason.clone(), + "last_healthcheck_at": status.last_healthcheck_at.clone(), + "last_heartbeat_at": status.last_heartbeat_at.clone(), + "log_path": status.log_path.clone(), + "state_dir": status.state_dir.clone(), + "sidecar_path": status.sidecar_path.clone(), + "restart_count": status.restart_count, + "watcher_count": status.watcher_count, + "last_restart_at": status.last_restart_at.clone(), + }, + "environment": environment, + "watcher_diagnostics": watcher_diagnostics, + "watch_folders": watch_folders, + "jobs": jobs, + "recent_items": recent_items, + "log_excerpt": log_excerpt, + }) +} + +#[cfg(test)] +mod tests { + use super::{build_diagnostics_bundle, trim_recent_items}; + use crate::shared::{ + DesktopRecentItem, DesktopRuntimeState, RuntimeEnvironmentProbe, RuntimeStatus, + WatcherDiagnostics, + }; + use serde_json::json; + + #[test] + fn trims_recent_items_without_duplicates() { + let items = trim_recent_items(vec![ + DesktopRecentItem { + kind: "import".into(), + title: "A.pdf".into(), + subtitle: None, + path: Some("/tmp/A.pdf".into()), + source_id: None, + created_at: "2026-04-17T10:00:00Z".into(), + }, + DesktopRecentItem { + kind: "import".into(), + title: "A.pdf".into(), + subtitle: None, + path: Some("/tmp/A.pdf".into()), + source_id: None, + created_at: "2026-04-17T10:00:01Z".into(), + }, + DesktopRecentItem { + kind: "import".into(), + title: "B.pdf".into(), + subtitle: None, + path: Some("/tmp/B.pdf".into()), + source_id: None, + created_at: "2026-04-17T10:00:02Z".into(), + }, + ]); + + assert_eq!(items.len(), 2); + assert_eq!(items[0].title, "A.pdf"); + assert_eq!(items[1].title, "B.pdf"); + } + + #[test] + fn builds_diagnostics_json_payload() { + let bundle = build_diagnostics_bundle( + &RuntimeStatus { + state: DesktopRuntimeState::Ready, + mode: "bundled".into(), + health_url: "http://127.0.0.1:8123/health".into(), + api_base_url: "http://127.0.0.1:8123/api/v1".into(), + pid: Some(321), + version: Some("0.3.0".into()), + last_error: None, + last_exit_reason: Some("sidecar exited".into()), + last_healthcheck_at: Some("2026-04-17T10:00:00Z".into()), + last_heartbeat_at: Some("2026-04-17T10:00:05Z".into()), + log_path: "/tmp/logs".into(), + state_dir: "/tmp/state".into(), + sidecar_path: Some("/tmp/lyranote-api-desktop".into()), + restart_count: 1, + watcher_count: 2, + watchers_paused: true, + last_restart_at: Some("2026-04-17T09:59:00Z".into()), + }, + json!([{ "path": "/tmp/notes" }]), + json!([{ "id": "job-1" }]), + json!([{ "title": "A.pdf" }]), + vec!["line-1".into()], + "2026-04-17T10:00:00Z".into(), + RuntimeEnvironmentProbe { + runtime_mode: "bundled".into(), + api_dir: "/tmp/api".into(), + resource_dir: Some("/tmp/resources".into()), + state_dir: "/tmp/state".into(), + log_dir: "/tmp/logs".into(), + sidecar_path: Some("/tmp/lyranote-api-desktop".into()), + }, + WatcherDiagnostics { + watcher_count: 2, + watched_paths: vec!["/tmp/notes".into()], + pending_paths_count: 1, + last_error: None, + paused: true, + }, + ); + + assert_eq!(bundle["runtime"]["mode"], "bundled"); + assert_eq!(bundle["runtime"]["restart_count"], 1); + assert_eq!(bundle["runtime"]["last_exit_reason"], "sidecar exited"); + assert_eq!(bundle["watch_folders"][0]["path"], "/tmp/notes"); + assert_eq!(bundle["environment"]["api_dir"], "/tmp/api"); + assert_eq!(bundle["watcher_diagnostics"]["pending_paths_count"], 1); + assert_eq!(bundle["watcher_diagnostics"]["paused"], true); + assert_eq!(bundle["jobs"][0]["id"], "job-1"); + assert_eq!(bundle["log_excerpt"][0], "line-1"); + } +} diff --git a/apps/desktop/src-tauri/src/app/menu.rs b/apps/desktop/src-tauri/src/app/menu.rs new file mode 100644 index 0000000..fbac486 --- /dev/null +++ b/apps/desktop/src-tauri/src/app/menu.rs @@ -0,0 +1,203 @@ +use crate::{ + app::{events::DesktopShell, windows::open_window}, + platform::reveal_path, + runtime::{DesktopRuntime, WatchManager}, + shared::DesktopWindowKind, +}; +use tauri::{ + menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, + AppHandle, Runtime, +}; + +const MENU_NEW_NOTE: &str = "desktop.new-note"; +const MENU_QUICK_CAPTURE: &str = "desktop.quick-capture"; +const MENU_QUICK_CHAT: &str = "desktop.quick-chat"; +const MENU_OPEN_KNOWLEDGE: &str = "desktop.open-knowledge"; +const MENU_OPEN_RECENT_IMPORTS: &str = "desktop.open-recent-imports"; +const MENU_TOGGLE_WATCHERS: &str = "desktop.toggle-watchers"; +const MENU_RESTART_RUNTIME: &str = "desktop.restart-runtime"; +const MENU_OPEN_LOGS: &str = "desktop.open-logs"; + +pub fn build_app_menu( + app: &AppHandle, + shell: &DesktopShell, + watch_manager: &WatchManager, +) -> Result, String> { + let shortcut = shell.shortcut_config(); + let quick_capture_accelerator = shortcut.enabled.then_some(shortcut.accelerator.as_str()); + let watchers_toggle_label = if watch_manager.is_paused() { + "恢复监听目录" + } else { + "暂停监听目录" + }; + let app_menu = Submenu::with_items( + app, + "LyraNote", + true, + &[ + &PredefinedMenuItem::about(app, Some("关于 LyraNote"), None) + .map_err(|error| error.to_string())?, + &PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?, + &MenuItem::with_id( + app, + MENU_QUICK_CAPTURE, + "Quick Capture", + true, + quick_capture_accelerator, + ) + .map_err(|error| error.to_string())?, + &MenuItem::with_id( + app, + MENU_QUICK_CHAT, + "快速提问", + true, + Some("CmdOrCtrl+Shift+K"), + ) + .map_err(|error| error.to_string())?, + &MenuItem::with_id( + app, + MENU_NEW_NOTE, + "新建收件箱笔记", + true, + Some("CmdOrCtrl+Shift+N"), + ) + .map_err(|error| error.to_string())?, + &PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?, + &PredefinedMenuItem::hide(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::hide_others(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::show_all(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?, + &PredefinedMenuItem::quit(app, None).map_err(|error| error.to_string())?, + ], + ) + .map_err(|error| error.to_string())?; + + let edit_menu = Submenu::with_items( + app, + "编辑", + true, + &[ + &PredefinedMenuItem::undo(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::redo(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?, + &PredefinedMenuItem::cut(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::copy(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::paste(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::select_all(app, None).map_err(|error| error.to_string())?, + ], + ) + .map_err(|error| error.to_string())?; + + let workspace_menu = Submenu::with_items( + app, + "工作台", + true, + &[ + &MenuItem::with_id( + app, + MENU_OPEN_KNOWLEDGE, + "打开知识库", + true, + Some("CmdOrCtrl+3"), + ) + .map_err(|error| error.to_string())?, + &MenuItem::with_id( + app, + MENU_OPEN_RECENT_IMPORTS, + "查看最近导入文件", + true, + None::<&str>, + ) + .map_err(|error| error.to_string())?, + &MenuItem::with_id( + app, + MENU_TOGGLE_WATCHERS, + watchers_toggle_label, + true, + None::<&str>, + ) + .map_err(|error| error.to_string())?, + &MenuItem::with_id( + app, + MENU_RESTART_RUNTIME, + "重启 Runtime", + true, + None::<&str>, + ) + .map_err(|error| error.to_string())?, + &MenuItem::with_id(app, MENU_OPEN_LOGS, "打开日志目录", true, None::<&str>) + .map_err(|error| error.to_string())?, + ], + ) + .map_err(|error| error.to_string())?; + + let window_menu = Submenu::with_items( + app, + "窗口", + true, + &[ + &PredefinedMenuItem::minimize(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::maximize(app, None).map_err(|error| error.to_string())?, + &PredefinedMenuItem::separator(app).map_err(|error| error.to_string())?, + &PredefinedMenuItem::close_window(app, None).map_err(|error| error.to_string())?, + ], + ) + .map_err(|error| error.to_string())?; + + Menu::with_items(app, &[&app_menu, &edit_menu, &workspace_menu, &window_menu]) + .map_err(|error| error.to_string()) +} + +pub fn handle_menu_event( + app: &AppHandle, + event: MenuEvent, + runtime: &DesktopRuntime, + shell: &DesktopShell, + watch_manager: &WatchManager, +) { + if event.id() == MENU_QUICK_CAPTURE { + let _ = open_window( + app, + DesktopWindowKind::QuickCapture, + Some(serde_json::json!({ "mode": "note" })), + ); + } else if event.id() == MENU_QUICK_CHAT { + let _ = open_window( + app, + DesktopWindowKind::Chat, + Some(serde_json::json!({ "initialMessage": "" })), + ); + } else if event.id() == MENU_NEW_NOTE { + let _ = open_window( + app, + DesktopWindowKind::QuickCapture, + Some(serde_json::json!({ "mode": "note", "focus": true })), + ); + } else if event.id() == MENU_OPEN_KNOWLEDGE { + let _ = open_window( + app, + DesktopWindowKind::Main, + Some(serde_json::json!({ "section": "knowledge" })), + ); + } else if event.id() == MENU_OPEN_RECENT_IMPORTS { + let _ = open_window( + app, + DesktopWindowKind::Main, + Some(serde_json::json!({ "section": "knowledge", "showRecentImports": true })), + ); + } else if event.id() == MENU_TOGGLE_WATCHERS { + let _ = watch_manager.toggle_paused(app, runtime.clone()); + if let Ok(menu) = build_app_menu(app, shell, watch_manager) { + let _ = app.set_menu(menu); + } + } else if event.id() == MENU_RESTART_RUNTIME { + let _ = runtime.restart(app); + } else if event.id() == MENU_OPEN_LOGS { + let log_path = runtime.status().log_path; + if !log_path.is_empty() { + let _ = reveal_path(&log_path); + } + } else if event.id() == "quit" { + app.exit(0); + } +} diff --git a/apps/desktop/src-tauri/src/app/mod.rs b/apps/desktop/src-tauri/src/app/mod.rs new file mode 100644 index 0000000..e74fec9 --- /dev/null +++ b/apps/desktop/src-tauri/src/app/mod.rs @@ -0,0 +1,11 @@ +pub mod bootstrap; +pub mod events; +pub mod menu; +pub mod shortcuts; +pub mod tray; +pub mod windows; + +pub use bootstrap::{handle_run_event, setup_app}; +pub use events::{build_diagnostics_bundle, emit_shell_event, trim_recent_items, DesktopShell}; +pub use menu::{build_app_menu, handle_menu_event}; +pub use windows::{focus_window, open_window}; diff --git a/apps/desktop/src-tauri/src/app/shortcuts.rs b/apps/desktop/src-tauri/src/app/shortcuts.rs new file mode 100644 index 0000000..360f6d0 --- /dev/null +++ b/apps/desktop/src-tauri/src/app/shortcuts.rs @@ -0,0 +1,85 @@ +use crate::shared::{DesktopShortcutConfig, DesktopWindowKind}; +use serde_json::json; +use tauri::{AppHandle, Runtime}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; + +use super::windows::open_window; + +pub fn normalize_shortcut_accelerator(accelerator: &str) -> String { + accelerator + .replace("CmdOrCtrl", "CommandOrControl") + .replace("CmdOrControl", "CommandOrControl") +} + +pub fn apply_global_shortcut( + app: &AppHandle, + config: &DesktopShortcutConfig, +) -> Result { + let manager = app.global_shortcut(); + manager + .unregister_all() + .map_err(|error| format!("failed to clear global shortcuts: {error}"))?; + + if !config.enabled { + return Ok(true); + } + + let normalized = normalize_shortcut_accelerator(&config.accelerator); + let action = config.action.clone(); + manager + .on_shortcut(normalized.as_str(), move |app, _shortcut, event| { + if event.state != ShortcutState::Pressed { + return; + } + let (kind, payload) = shortcut_target(&action); + let _ = open_window(app, kind, payload); + }) + .map_err(|error| format!("failed to register global shortcut: {error}"))?; + + Ok(true) +} + +fn shortcut_target(action: &str) -> (DesktopWindowKind, Option) { + match action { + "quick-chat" => (DesktopWindowKind::Chat, Some(json!({ "initialMessage": "" }))), + _ => ( + DesktopWindowKind::QuickCapture, + Some(json!({ "mode": "note" })), + ), + } +} + +#[cfg(test)] +mod tests { + use super::{normalize_shortcut_accelerator, shortcut_target}; + use crate::shared::DesktopWindowKind; + + #[test] + fn normalizes_menu_style_accelerators_for_global_shortcuts() { + assert_eq!( + normalize_shortcut_accelerator("CmdOrCtrl+Shift+L"), + "CommandOrControl+Shift+L" + ); + assert_eq!( + normalize_shortcut_accelerator("CmdOrControl+K"), + "CommandOrControl+K" + ); + } + + #[test] + fn maps_shortcut_actions_to_window_routes() { + let (kind, payload) = shortcut_target("quick-chat"); + assert!(matches!(kind, DesktopWindowKind::Chat)); + assert_eq!( + payload.expect("chat payload")["initialMessage"], + serde_json::Value::String(String::new()) + ); + + let (kind, payload) = shortcut_target("quick-capture"); + assert!(matches!(kind, DesktopWindowKind::QuickCapture)); + assert_eq!( + payload.expect("capture payload")["mode"], + serde_json::Value::String("note".into()) + ); + } +} diff --git a/apps/desktop/src-tauri/src/app/tray.rs b/apps/desktop/src-tauri/src/app/tray.rs new file mode 100644 index 0000000..ff2954a --- /dev/null +++ b/apps/desktop/src-tauri/src/app/tray.rs @@ -0,0 +1,3 @@ +#![allow(dead_code)] + +// Tray / Dock integrations will land here without further flattening lib.rs. diff --git a/apps/desktop/src-tauri/src/app/windows.rs b/apps/desktop/src-tauri/src/app/windows.rs new file mode 100644 index 0000000..6f13246 --- /dev/null +++ b/apps/desktop/src-tauri/src/app/windows.rs @@ -0,0 +1,87 @@ +use crate::shared::DesktopWindowKind; +use serde_json::Value; +use std::{thread, time::Duration}; +use tauri::{ + AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder, +}; + +#[cfg(target_os = "macos")] +use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; + +pub const WINDOW_ROUTE_EVENT_NAME: &str = "desktop://route"; + +pub fn open_window( + app: &AppHandle, + kind: DesktopWindowKind, + payload: Option, +) -> Result<(), String> { + let label = kind.label(); + if let Some(window) = app.get_webview_window(label) { + focus_and_route(&window, payload)?; + return Ok(()); + } + + let window = WebviewWindowBuilder::new(app, label, WebviewUrl::default()) + .title(kind.title()) + .inner_size(window_size(&kind).0, window_size(&kind).1) + .min_inner_size(window_min_size(&kind).0, window_min_size(&kind).1) + .decorations(false) + .transparent(true) + .center() + .resizable(true) + .visible(true) + .always_on_top(matches!(kind, DesktopWindowKind::QuickCapture)) + .build() + .map_err(|error| format!("failed to open window '{}': {error}", label))?; + + #[cfg(target_os = "macos")] + apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, Some(12.0)) + .map_err(|error| error.to_string())?; + + focus_and_route(&window, payload) +} + +pub fn focus_window(app: &AppHandle, label: &str) -> Result<(), String> { + let window = app + .get_webview_window(label) + .ok_or_else(|| format!("window '{label}' is not available"))?; + window.show().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + Ok(()) +} + +fn focus_and_route( + window: &WebviewWindow, + payload: Option, +) -> Result<(), String> { + window.show().map_err(|error| error.to_string())?; + window.unminimize().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + if let Some(payload) = payload { + let target = window.label().to_string(); + let app = window.app_handle().clone(); + thread::spawn(move || { + thread::sleep(Duration::from_millis(250)); + let _ = app.emit_to(target, WINDOW_ROUTE_EVENT_NAME, payload); + }); + } + Ok(()) +} + +fn window_size(kind: &DesktopWindowKind) -> (f64, f64) { + match kind { + DesktopWindowKind::Main => (1280.0, 800.0), + DesktopWindowKind::QuickCapture => (560.0, 420.0), + DesktopWindowKind::Chat => (1024.0, 760.0), + DesktopWindowKind::SourceDetail => (980.0, 760.0), + } +} + +fn window_min_size(kind: &DesktopWindowKind) -> (f64, f64) { + match kind { + DesktopWindowKind::Main => (900.0, 600.0), + DesktopWindowKind::QuickCapture => (460.0, 320.0), + DesktopWindowKind::Chat => (720.0, 520.0), + DesktopWindowKind::SourceDetail => (720.0, 520.0), + } +} diff --git a/apps/desktop/src-tauri/src/commands/diagnostics.rs b/apps/desktop/src-tauri/src/commands/diagnostics.rs new file mode 100644 index 0000000..63345cf --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/diagnostics.rs @@ -0,0 +1,58 @@ +use crate::{ + app::build_diagnostics_bundle, + runtime::{ + authenticated_get_json, log_excerpt, now_iso_string, sidecar::runtime_environment_probe, + DesktopRuntime, WatchManager, + }, + shared::DesktopDiagnosticsBundleMeta, +}; +use std::{fs, path::PathBuf}; +use tauri::{AppHandle, Manager, State}; + +#[tauri::command] +pub async fn diagnostics_export( + app: AppHandle, + runtime: State<'_, DesktopRuntime>, + watch_manager: State<'_, WatchManager>, +) -> Result { + let status = runtime.status(); + let watch_folders = authenticated_get_json(runtime.inner(), "/watch-folders") + .unwrap_or_else(|_| serde_json::json!({ "items": [] })); + let jobs = authenticated_get_json(runtime.inner(), "/jobs") + .unwrap_or_else(|_| serde_json::json!({ "items": [] })); + let recent_items = serde_json::to_value(crate::runtime::fetch_recent_items(runtime.inner())?) + .map_err(|error| format!("failed to serialize recent items: {error}"))?; + let generated_at = now_iso_string(); + let bundle = build_diagnostics_bundle( + &status, + watch_folders, + jobs, + recent_items, + log_excerpt(&status.log_path, 80), + generated_at.clone(), + runtime_environment_probe(&app, Some(status.mode.clone())), + watch_manager.diagnostics_snapshot(), + ); + let diagnostics_dir = app + .path() + .app_data_dir() + .map(|path| path.join("desktop").join("diagnostics")) + .unwrap_or_else(|_| { + PathBuf::from(format!( + "{}/.lyranote/desktop/diagnostics", + std::env::var("HOME").unwrap_or_default() + )) + }); + fs::create_dir_all(&diagnostics_dir) + .map_err(|error| format!("failed to create diagnostics dir: {error}"))?; + let path = diagnostics_dir.join(format!("diagnostics-{generated_at}.json")); + let raw = serde_json::to_string_pretty(&bundle) + .map_err(|error| format!("failed to serialize diagnostics bundle: {error}"))?; + fs::write(&path, raw) + .map_err(|error| format!("failed to write diagnostics bundle: {error}"))?; + Ok(DesktopDiagnosticsBundleMeta { + path: path.display().to_string(), + generated_at, + log_path: status.log_path, + }) +} diff --git a/apps/desktop/src-tauri/src/commands/files.rs b/apps/desktop/src-tauri/src/commands/files.rs new file mode 100644 index 0000000..27820b1 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/files.rs @@ -0,0 +1,35 @@ +use crate::{ + native::{compute_sha256_for_path, probe_file_metadata}, + platform::{copy_path_to_clipboard, open_path_with_default_app, show_notification}, + shared::{DesktopFileProbe, DesktopHashResult, DesktopNotification}, +}; +use std::path::Path; +use tauri::AppHandle; + +#[tauri::command] +pub async fn notification_show( + app: AppHandle, + notification: DesktopNotification, +) -> Result<(), String> { + show_notification(&app, notification) +} + +#[tauri::command] +pub async fn file_open_default(path: String) -> Result<(), String> { + open_path_with_default_app(&path) +} + +#[tauri::command] +pub async fn file_copy_path(path: String) -> Result<(), String> { + copy_path_to_clipboard(&path) +} + +#[tauri::command] +pub async fn file_probe_metadata(path: String) -> Result { + probe_file_metadata(Path::new(&path)) +} + +#[tauri::command] +pub async fn file_compute_hash(path: String) -> Result { + compute_sha256_for_path(Path::new(&path)) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..5360730 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod diagnostics; +pub mod files; +pub mod runtime; +pub mod security; +pub mod shell; diff --git a/apps/desktop/src-tauri/src/commands/runtime.rs b/apps/desktop/src-tauri/src/commands/runtime.rs new file mode 100644 index 0000000..7ba0c4f --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/runtime.rs @@ -0,0 +1,50 @@ +use crate::{ + platform::{ + dialog_pick_sources as pick_sources, dialog_pick_watch_folder as pick_watch_folder, + reveal_path, + }, + runtime::{DesktopRuntime, WatchManager}, + shared::{RuntimeStatus, SelectedPath, WatchFolderRegistration}, +}; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn runtime_status( + app: AppHandle, + runtime: State<'_, DesktopRuntime>, +) -> Result { + runtime.ensure_started(&app) +} + +#[tauri::command] +pub async fn runtime_restart( + app: AppHandle, + runtime: State<'_, DesktopRuntime>, +) -> Result { + runtime.restart(&app) +} + +#[tauri::command] +pub async fn dialog_pick_sources() -> Result, String> { + pick_sources() +} + +#[tauri::command] +pub async fn dialog_pick_watch_folder() -> Result, String> { + pick_watch_folder() +} + +#[tauri::command] +pub async fn file_reveal(path: String) -> Result<(), String> { + reveal_path(&path) +} + +#[tauri::command] +pub async fn watch_folders_sync( + app: AppHandle, + runtime: State<'_, DesktopRuntime>, + watch_manager: State<'_, WatchManager>, + folders: Vec, +) -> Result<(), String> { + watch_manager.sync_folders(&app, runtime.inner().clone(), folders) +} diff --git a/apps/desktop/src-tauri/src/commands/security.rs b/apps/desktop/src-tauri/src/commands/security.rs new file mode 100644 index 0000000..3507e05 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/security.rs @@ -0,0 +1,52 @@ +use crate::{ + security::{ + clear_session, delete_secret, get_secret, hydrate_session, list_secret_keys, store_secret, + store_session, + }, + shared::{DesktopSecretKey, SecureSession, SecureSessionRecord}, +}; +use tauri::{AppHandle, Runtime}; + +#[tauri::command] +pub async fn session_hydrate() -> Result { + hydrate_session() +} + +#[tauri::command] +pub async fn session_store(payload: SecureSessionRecord) -> Result { + store_session(payload) +} + +#[tauri::command] +pub async fn session_clear() -> Result<(), String> { + clear_session() +} + +#[tauri::command] +pub async fn secure_secret_store( + app: AppHandle, + key: String, + value: String, +) -> Result { + store_secret(&app, key, value) +} + +#[tauri::command] +pub async fn secure_secret_get(key: String) -> Result, String> { + get_secret(key) +} + +#[tauri::command] +pub async fn secure_secret_delete( + app: AppHandle, + key: String, +) -> Result<(), String> { + delete_secret(&app, key) +} + +#[tauri::command] +pub async fn secure_secret_list_keys( + app: AppHandle, +) -> Result, String> { + list_secret_keys(&app) +} diff --git a/apps/desktop/src-tauri/src/commands/shell.rs b/apps/desktop/src-tauri/src/commands/shell.rs new file mode 100644 index 0000000..6ae783d --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/shell.rs @@ -0,0 +1,69 @@ +use crate::{ + app::{build_app_menu, open_window, DesktopShell}, + runtime::{fetch_recent_items, DesktopRuntime, WatchManager}, + shared::{DesktopRecentItem, DesktopShortcutConfig, DesktopWindowKind, RuntimeStatus}, +}; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn global_shortcut_status( + shell: State<'_, DesktopShell>, +) -> Result { + Ok(shell.shortcut_config()) +} + +#[tauri::command] +pub async fn global_shortcut_update( + app: AppHandle, + shell: State<'_, DesktopShell>, + watch_manager: State<'_, WatchManager>, + config: DesktopShortcutConfig, +) -> Result { + let next = shell.update_shortcut(&app, config)?; + let menu = build_app_menu(&app, shell.inner(), watch_manager.inner())?; + let _ = app.set_menu(menu); + Ok(next) +} + +#[tauri::command] +pub async fn tray_toggle_watchers( + app: AppHandle, + runtime: State<'_, DesktopRuntime>, + watch_manager: State<'_, WatchManager>, + shell: State<'_, DesktopShell>, +) -> Result { + watch_manager.toggle_paused(&app, runtime.inner().clone())?; + let menu = build_app_menu(&app, shell.inner(), watch_manager.inner())?; + let _ = app.set_menu(menu); + Ok(runtime.status()) +} + +#[tauri::command] +pub async fn quick_capture_open(app: AppHandle) -> Result<(), String> { + open_window( + &app, + DesktopWindowKind::QuickCapture, + Some(serde_json::json!({ "mode": "note" })), + ) +} + +#[tauri::command] +pub async fn window_open( + app: AppHandle, + kind: DesktopWindowKind, + payload: Option, +) -> Result<(), String> { + open_window(&app, kind, payload) +} + +#[tauri::command] +pub async fn window_focus(app: AppHandle, label: String) -> Result<(), String> { + crate::app::focus_window(&app, &label) +} + +#[tauri::command] +pub async fn recent_items_list( + runtime: State<'_, DesktopRuntime>, +) -> Result, String> { + fetch_recent_items(runtime.inner()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 06b92cf..d22b68f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,79 +1,102 @@ -use tauri::{ - menu::{MenuBuilder, MenuItemBuilder}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - Manager, Runtime, +mod app; +mod commands; +mod native; +mod platform; +mod runtime; +mod security; +mod shared; + +use app::{build_app_menu, handle_menu_event, handle_run_event, setup_app, DesktopShell}; +use commands::{ + diagnostics::diagnostics_export, + files::{ + file_compute_hash, file_copy_path, file_open_default, file_probe_metadata, + notification_show, + }, + runtime::{ + dialog_pick_sources, dialog_pick_watch_folder, file_reveal, runtime_restart, + runtime_status, watch_folders_sync, + }, + security::{ + secure_secret_delete, secure_secret_get, secure_secret_list_keys, secure_secret_store, + session_clear, session_hydrate, session_store, + }, + shell::{ + global_shortcut_status, global_shortcut_update, quick_capture_open, + recent_items_list, tray_toggle_watchers, window_focus, window_open, + }, }; +use runtime::{DesktopRuntime, WatchManager}; +use tauri::Manager; +#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let runtime = DesktopRuntime::default(); + let watch_manager = WatchManager::default(); + let shell = DesktopShell::default(); + tauri::Builder::default() + .manage(runtime) + .manage(watch_manager) + .manage(shell) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_store::Builder::default().build()) - .setup(|app| { - setup_tray(app)?; - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - greet, - show_window, - hide_window, - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} - -fn setup_tray(app: &tauri::App) -> tauri::Result<()> { - let show = MenuItemBuilder::with_id("show", "Open LyraNote").build(app)?; - let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; - let menu = MenuBuilder::new(app).items(&[&show, &quit]).build()?; - - let _tray = TrayIconBuilder::new() - .menu(&menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id().as_ref() { - "show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) + .menu(|app| { + let shell = app.state::(); + let watch_manager = app.state::(); + match build_app_menu(app, shell.inner(), watch_manager.inner()) { + Ok(menu) => Ok(menu), + Err(error) => { + eprintln!("[desktop-shell] failed to build app menu: {error}"); + tauri::menu::Menu::new(app) } } - "quit" => app.exit(0), - _ => {} }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - let app = tray.app_handle(); - 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(); - } - } - } + .on_menu_event(|app, event| { + let runtime = app.state::(); + let shell = app.state::(); + let watch_manager = app.state::(); + handle_menu_event( + app, + event, + runtime.inner(), + shell.inner(), + watch_manager.inner(), + ); }) - .build(app)?; - - Ok(()) -} - -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! Welcome to LyraNote.", name) -} - -#[tauri::command] -async fn show_window(window: tauri::WebviewWindow) -> Result<(), String> { - window.show().map_err(|e| e.to_string())?; - window.set_focus().map_err(|e| e.to_string()) -} - -#[tauri::command] -async fn hide_window(window: tauri::WebviewWindow) -> Result<(), String> { - window.hide().map_err(|e| e.to_string()) + .invoke_handler(tauri::generate_handler![ + runtime_status, + runtime_restart, + session_hydrate, + session_store, + session_clear, + secure_secret_store, + secure_secret_get, + secure_secret_delete, + secure_secret_list_keys, + dialog_pick_sources, + dialog_pick_watch_folder, + file_reveal, + notification_show, + watch_folders_sync, + global_shortcut_status, + global_shortcut_update, + tray_toggle_watchers, + quick_capture_open, + window_open, + window_focus, + recent_items_list, + diagnostics_export, + file_open_default, + file_copy_path, + file_probe_metadata, + file_compute_hash + ]) + .setup(setup_app) + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(handle_run_event); } diff --git a/apps/desktop/src-tauri/src/native/diff.rs b/apps/desktop/src-tauri/src/native/diff.rs new file mode 100644 index 0000000..65d175f --- /dev/null +++ b/apps/desktop/src-tauri/src/native/diff.rs @@ -0,0 +1,3 @@ +#![allow(dead_code)] + +// Reserved for future diff/merge helpers. diff --git a/apps/desktop/src-tauri/src/native/file_probe.rs b/apps/desktop/src-tauri/src/native/file_probe.rs new file mode 100644 index 0000000..c2f2352 --- /dev/null +++ b/apps/desktop/src-tauri/src/native/file_probe.rs @@ -0,0 +1,119 @@ +use crate::shared::DesktopFileProbe; +use std::{ + fs, + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +use super::pdf_probe::probe_pdf_page_count; + +pub fn probe_file_metadata(path: &Path) -> Result { + if !path.exists() { + return Err(format!("path does not exist: {}", path.display())); + } + + let metadata = + fs::metadata(path).map_err(|error| format!("failed to read metadata: {error}"))?; + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + let mime_hint = guess_mime_hint(path, metadata.is_dir()); + let pdf_page_count = if metadata.is_dir() { + None + } else { + probe_pdf_page_count(path)? + }; + + Ok(DesktopFileProbe { + path: path.display().to_string(), + name: path + .file_name() + .and_then(|value| value.to_str()) + .map(str::to_string) + .unwrap_or_else(|| path.display().to_string()), + is_dir: metadata.is_dir(), + size_bytes: (!metadata.is_dir()).then_some(metadata.len()), + extension, + mime_hint, + created_at: metadata.created().ok().map(system_time_to_epoch_string), + modified_at: metadata.modified().ok().map(system_time_to_epoch_string), + pdf_page_count, + }) +} + +fn system_time_to_epoch_string(value: SystemTime) -> String { + value + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +fn guess_mime_hint(path: &Path, is_dir: bool) -> Option { + if is_dir { + return Some("inode/directory".to_string()); + } + match path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .as_deref() + { + Some("pdf") => Some("application/pdf".to_string()), + Some("md") => Some("text/markdown".to_string()), + Some("txt") => Some("text/plain".to_string()), + Some("docx") => Some( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document".to_string(), + ), + Some("json") => Some("application/json".to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn temp_path(name: &str, ext: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!("lyranote-{name}-{suffix}.{ext}")) + } + + #[test] + fn probes_text_file_metadata() { + let path = temp_path("probe", "md"); + fs::write(&path, "# notes").unwrap(); + + let probe = probe_file_metadata(&path).unwrap(); + assert_eq!(probe.name, path.file_name().unwrap().to_string_lossy()); + assert_eq!(probe.extension.as_deref(), Some("md")); + assert_eq!(probe.mime_hint.as_deref(), Some("text/markdown")); + assert_eq!(probe.size_bytes, Some(7)); + assert!(!probe.is_dir); + + let _ = fs::remove_file(path); + } + + #[test] + fn probes_pdf_page_count_when_applicable() { + let path = temp_path("probe-pdf", "pdf"); + let sample = + b"%PDF-1.4\n1 0 obj << /Type /Page >> endobj\n2 0 obj << /Type /Page >> endobj\n"; + fs::write(&path, sample).unwrap(); + + let probe = probe_file_metadata(&path).unwrap(); + assert_eq!(probe.mime_hint.as_deref(), Some("application/pdf")); + assert_eq!(probe.pdf_page_count, Some(2)); + + let _ = fs::remove_file(path); + } +} diff --git a/apps/desktop/src-tauri/src/native/hashing.rs b/apps/desktop/src-tauri/src/native/hashing.rs new file mode 100644 index 0000000..4b1fda1 --- /dev/null +++ b/apps/desktop/src-tauri/src/native/hashing.rs @@ -0,0 +1,74 @@ +use crate::shared::DesktopHashResult; +use sha2::{Digest, Sha256}; +use std::{ + fs::File, + io::{BufReader, Read}, + path::Path, +}; + +pub fn compute_sha256_for_path(path: &Path) -> Result { + if !path.exists() { + return Err(format!("file does not exist: {}", path.display())); + } + if !path.is_file() { + return Err(format!("path is not a file: {}", path.display())); + } + + let file = File::open(path).map_err(|error| format!("failed to open file: {error}"))?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + let mut bytes_processed = 0_u64; + + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| format!("failed to read file: {error}"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + bytes_processed += read as u64; + } + + let digest = format!("{:x}", hasher.finalize()); + Ok(DesktopHashResult { + path: path.display().to_string(), + algorithm: "sha256".to_string(), + digest, + bytes_processed, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn temp_path(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!("lyranote-{name}-{suffix}.txt")) + } + + #[test] + fn computes_stable_sha256_digest() { + let path = temp_path("hash"); + fs::write(&path, b"hello world").unwrap(); + + let hash = compute_sha256_for_path(&path).unwrap(); + assert_eq!( + hash.digest, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + assert_eq!(hash.bytes_processed, 11); + + let _ = fs::remove_file(path); + } +} diff --git a/apps/desktop/src-tauri/src/native/mod.rs b/apps/desktop/src-tauri/src/native/mod.rs new file mode 100644 index 0000000..25d8f6d --- /dev/null +++ b/apps/desktop/src-tauri/src/native/mod.rs @@ -0,0 +1,10 @@ +#![allow(dead_code)] + +pub mod diff; +pub mod file_probe; +pub mod hashing; +pub mod pdf_probe; +pub mod preprocess; + +pub use file_probe::probe_file_metadata; +pub use hashing::compute_sha256_for_path; diff --git a/apps/desktop/src-tauri/src/native/pdf_probe.rs b/apps/desktop/src-tauri/src/native/pdf_probe.rs new file mode 100644 index 0000000..95d20ba --- /dev/null +++ b/apps/desktop/src-tauri/src/native/pdf_probe.rs @@ -0,0 +1,65 @@ +use std::{fs, path::Path}; + +pub fn probe_pdf_page_count(path: &Path) -> Result, String> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + if extension.as_deref() != Some("pdf") { + return Ok(None); + } + + let content = fs::read(path).map_err(|error| format!("failed to read pdf: {error}"))?; + if !content.starts_with(b"%PDF-") { + return Ok(None); + } + + let pattern = b"/Type /Page"; + let mut count = 0_u32; + let mut index = 0_usize; + while let Some(relative) = content[index..] + .windows(pattern.len()) + .position(|window| window == pattern) + { + let absolute = index + relative; + let next = content.get(absolute + pattern.len()).copied(); + if next != Some(b's') { + count += 1; + } + index = absolute + pattern.len(); + if index >= content.len() { + break; + } + } + + Ok((count > 0).then_some(count)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn temp_path(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!("lyranote-{name}-{suffix}.pdf")) + } + + #[test] + fn estimates_pdf_page_count_from_page_markers() { + let path = temp_path("pdf-probe"); + let sample = b"%PDF-1.4\n1 0 obj << /Type /Page >> endobj\n2 0 obj << /Type /Page >> endobj\n3 0 obj << /Type /Pages >> endobj\n"; + fs::write(&path, sample).unwrap(); + + assert_eq!(probe_pdf_page_count(&path).unwrap(), Some(2)); + + let _ = fs::remove_file(path); + } +} diff --git a/apps/desktop/src-tauri/src/native/preprocess.rs b/apps/desktop/src-tauri/src/native/preprocess.rs new file mode 100644 index 0000000..23c9744 --- /dev/null +++ b/apps/desktop/src-tauri/src/native/preprocess.rs @@ -0,0 +1,21 @@ +pub fn normalize_text(input: &str) -> String { + input + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_whitespace_without_changing_words() { + assert_eq!( + normalize_text(" LyraNote\n\n desktop\tplatform "), + "LyraNote desktop platform".to_string() + ); + } +} diff --git a/apps/desktop/src-tauri/src/platform/clipboard.rs b/apps/desktop/src-tauri/src/platform/clipboard.rs new file mode 100644 index 0000000..2002935 --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/clipboard.rs @@ -0,0 +1,22 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +pub fn copy_path_to_clipboard(path: &str) -> Result<(), String> { + let mut child = Command::new("pbcopy") + .stdin(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to access clipboard: {error}"))?; + if let Some(stdin) = child.stdin.as_mut() { + stdin + .write_all(path.as_bytes()) + .map_err(|error| format!("failed to write clipboard contents: {error}"))?; + } + let status = child + .wait() + .map_err(|error| format!("failed to finalize clipboard write: {error}"))?; + if status.success() { + Ok(()) + } else { + Err("clipboard write failed".to_string()) + } +} diff --git a/apps/desktop/src-tauri/src/platform/dialogs.rs b/apps/desktop/src-tauri/src/platform/dialogs.rs new file mode 100644 index 0000000..d19d930 --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/dialogs.rs @@ -0,0 +1,74 @@ +use crate::shared::SelectedPath; +use std::path::PathBuf; +use std::process::Command; + +pub fn dialog_pick_sources() -> Result, String> { + let script = r#"set chosenFiles to choose file with multiple selections allowed +set output to "" +repeat with aFile in chosenFiles + set output to output & POSIX path of aFile & linefeed +end repeat +return output"#; + + let output = Command::new("osascript") + .args(["-e", script]) + .output() + .map_err(|error| format!("failed to open file picker: {error}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") { + return Ok(Vec::new()); + } + return Err(stderr.trim().to_string()); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| path_to_selected(line, false)) + .collect()) +} + +pub fn dialog_pick_watch_folder() -> Result, String> { + let script = r#"set chosenFolder to choose folder +return POSIX path of chosenFolder"#; + + let output = Command::new("osascript") + .args(["-e", script]) + .output() + .map_err(|error| format!("failed to open folder picker: {error}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") { + return Ok(None); + } + return Err(stderr.trim().to_string()); + } + + let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if raw.is_empty() { + return Ok(None); + } + Ok(Some(path_to_selected(&raw, true))) +} + +fn path_to_selected(path: &str, is_dir: bool) -> SelectedPath { + let path_buf = PathBuf::from(path); + let mime_hint = path_buf + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + SelectedPath { + path: path.to_string(), + name: path_buf + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(path) + .to_string(), + is_dir, + mime_hint, + } +} diff --git a/apps/desktop/src-tauri/src/platform/file_system.rs b/apps/desktop/src-tauri/src/platform/file_system.rs new file mode 100644 index 0000000..08298dd --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/file_system.rs @@ -0,0 +1,26 @@ +use std::process::Command; + +pub fn reveal_path(path: &str) -> Result<(), String> { + let output = Command::new("open") + .args(["-R", path]) + .output() + .map_err(|error| format!("failed to reveal path: {error}"))?; + + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +pub fn open_path_with_default_app(path: &str) -> Result<(), String> { + let output = Command::new("open") + .arg(path) + .output() + .map_err(|error| format!("failed to open path: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} diff --git a/apps/desktop/src-tauri/src/platform/macos.rs b/apps/desktop/src-tauri/src/platform/macos.rs new file mode 100644 index 0000000..3f4641c --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/macos.rs @@ -0,0 +1,3 @@ +#![allow(dead_code)] + +// macOS-specific platform helpers will live here as the Rust desktop shell grows. diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-tauri/src/platform/mod.rs new file mode 100644 index 0000000..63d0b09 --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/mod.rs @@ -0,0 +1,10 @@ +pub mod clipboard; +pub mod dialogs; +pub mod file_system; +pub mod macos; +pub mod notifications; + +pub use clipboard::copy_path_to_clipboard; +pub use dialogs::{dialog_pick_sources, dialog_pick_watch_folder}; +pub use file_system::{open_path_with_default_app, reveal_path}; +pub use notifications::show_notification; diff --git a/apps/desktop/src-tauri/src/platform/notifications.rs b/apps/desktop/src-tauri/src/platform/notifications.rs new file mode 100644 index 0000000..d7661ea --- /dev/null +++ b/apps/desktop/src-tauri/src/platform/notifications.rs @@ -0,0 +1,18 @@ +use crate::shared::DesktopNotification; +use tauri::AppHandle; +use tauri_plugin_notification::NotificationExt; + +pub fn show_notification(app: &AppHandle, notification: DesktopNotification) -> Result<(), String> { + let mut builder = app + .notification() + .builder() + .title(notification.title) + .body(notification.body) + .summary(notification.kind); + if let Some(route) = notification.route.clone() { + builder = builder.extra("route", route); + } + builder + .show() + .map_err(|error| format!("failed to show notification: {error}")) +} diff --git a/apps/desktop/src-tauri/src/runtime/health.rs b/apps/desktop/src-tauri/src/runtime/health.rs new file mode 100644 index 0000000..5aaf46b --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime/health.rs @@ -0,0 +1,96 @@ +use crate::shared::SidecarEvent; +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + time::Duration, +}; + +const RUNTIME_RAW_EVENT_NAME: &str = "runtime://event"; +const JOBS_EVENT_NAME: &str = "jobs://progress"; +const SYNC_EVENT_NAME: &str = "sync://state"; +const IMPORT_EVENT_NAME: &str = "import://result"; + +pub fn parse_sidecar_event(line: &str) -> Option { + serde_json::from_str::(line).ok() +} + +pub fn map_sidecar_event_name(event_type: &str) -> Option<&'static str> { + match event_type { + "runtime.ready" | "runtime.state" => Some(RUNTIME_RAW_EVENT_NAME), + "job.progress" | "job.completed" | "job.failed" => Some(JOBS_EVENT_NAME), + "sync.changed" => Some(SYNC_EVENT_NAME), + "import.failed" | "import.result" => Some(IMPORT_EVENT_NAME), + _ => None, + } +} + +pub(crate) fn ping_health(url: &str) -> Result<(), String> { + let address = url + .trim_start_matches("http://") + .trim_end_matches("/health") + .parse::() + .map_err(|error| format!("invalid health url '{url}': {error}"))?; + + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(1)) + .map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(1))) + .map_err(|error| error.to_string())?; + stream + .write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .map_err(|error| error.to_string())?; + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .map_err(|error| error.to_string())?; + if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") { + Ok(()) + } else { + Err("health check returned non-200".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::{map_sidecar_event_name, parse_sidecar_event}; + + #[test] + fn parses_sidecar_json_lines() { + let line = r#"{"type":"runtime.ready","payload":{"version":"0.1.0"},"occurred_at":"2026-04-17T12:00:00Z"}"#; + let parsed = parse_sidecar_event(line).expect("event should parse"); + assert_eq!(parsed.event_type, "runtime.ready"); + assert_eq!( + parsed + .payload + .get("version") + .and_then(|value| value.as_str()), + Some("0.1.0") + ); + } + + #[test] + fn maps_supported_event_names() { + assert_eq!( + map_sidecar_event_name("runtime.ready"), + Some("runtime://event") + ); + assert_eq!( + map_sidecar_event_name("job.progress"), + Some("jobs://progress") + ); + assert_eq!( + map_sidecar_event_name("job.completed"), + Some("jobs://progress") + ); + assert_eq!(map_sidecar_event_name("sync.changed"), Some("sync://state")); + assert_eq!( + map_sidecar_event_name("import.failed"), + Some("import://result") + ); + assert_eq!(map_sidecar_event_name("unknown"), None); + } +} diff --git a/apps/desktop/src-tauri/src/runtime/jobs.rs b/apps/desktop/src-tauri/src/runtime/jobs.rs new file mode 100644 index 0000000..8d8f760 --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime/jobs.rs @@ -0,0 +1,3 @@ +#![allow(dead_code)] + +// Reserved for future Rust-side job orchestration helpers. diff --git a/apps/desktop/src-tauri/src/runtime/mod.rs b/apps/desktop/src-tauri/src/runtime/mod.rs new file mode 100644 index 0000000..6370095 --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime/mod.rs @@ -0,0 +1,11 @@ +pub mod health; +pub mod jobs; +pub mod sidecar; +pub mod state; +pub mod supervisor; +pub mod watchers; + +pub use sidecar::{ + authenticated_get_json, fetch_recent_items, log_excerpt, now_iso_string, post_global_import, +}; +pub use state::{DesktopRuntime, WatchManager}; diff --git a/apps/desktop/src-tauri/src/runtime/sidecar.rs b/apps/desktop/src-tauri/src/runtime/sidecar.rs new file mode 100644 index 0000000..4221975 --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime/sidecar.rs @@ -0,0 +1,391 @@ +use crate::{ + app::trim_recent_items, + security::hydrate_session, + shared::{DesktopRecentItem, RuntimeEnvironmentProbe}, +}; +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::Command, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tauri::{AppHandle, Manager, Runtime}; + +use super::state::DesktopRuntime; + +/// Ports reserved by other LyraNote services — the desktop sidecar must never use these. +const RESERVED_PORTS: &[u16] = &[3000, 5432, 5433, 6379, 8000, 9000]; + +pub fn authenticated_get_json( + runtime: &DesktopRuntime, + route: &str, +) -> Result { + let body = authenticated_get(runtime, route)?; + serde_json::from_str(&body) + .map_err(|error| format!("failed to parse response for '{route}': {error}")) +} + +pub fn fetch_recent_items(runtime: &DesktopRuntime) -> Result, String> { + let body = authenticated_get(runtime, "/recent-imports")?; + let payload: serde_json::Value = serde_json::from_str(&body) + .map_err(|error| format!("failed to parse recent imports: {error}"))?; + let items = payload + .get("items") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| { + let path = item + .get("path") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let title = item + .get("title") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| { + path.as_ref().and_then(|value| { + Path::new(value) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) + })?; + Some(DesktopRecentItem { + kind: "import".to_string(), + title, + subtitle: path.clone(), + path, + source_id: item + .get("source_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + created_at: item + .get("imported_at") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + }) + }) + .collect::>(); + Ok(trim_recent_items(items)) +} + +pub fn log_excerpt(log_path: &str, max_lines: usize) -> Vec { + let path = Path::new(log_path); + let candidate = if path.is_dir() { + newest_log_file(path) + } else if path.exists() { + Some(path.to_path_buf()) + } else { + None + }; + let Some(candidate) = candidate else { + return Vec::new(); + }; + let Ok(raw) = std::fs::read_to_string(candidate) else { + return Vec::new(); + }; + let mut lines = raw + .lines() + .rev() + .take(max_lines) + .map(str::to_string) + .collect::>(); + lines.reverse(); + lines +} + +pub fn post_global_import(runtime: &DesktopRuntime, path: &str) -> Result<(), String> { + post_desktop_import(runtime, "/sources/global/import-path", path) +} + +pub(crate) fn post_watch_import(runtime: &DesktopRuntime, path: &str) -> Result<(), String> { + post_desktop_import(runtime, "/watch-folders/import", path) +} + +pub fn now_iso_string() -> String { + let epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("{epoch}") +} + +pub fn default_log_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../api/logs") + .canonicalize() + .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../api/logs")) +} + +pub fn resolved_log_dir(app: &AppHandle) -> PathBuf { + app.path() + .app_log_dir() + .unwrap_or_else(|_| default_log_dir()) +} + +pub fn resolved_state_dir(app: &AppHandle) -> PathBuf { + app.path() + .app_data_dir() + .map(|path| path.join("desktop")) + .unwrap_or_else(|_| { + PathBuf::from(format!( + "{}/.lyranote/desktop", + std::env::var("HOME").unwrap_or_default() + )) + }) +} + +pub fn detect_runtime_mode(app: &AppHandle) -> String { + if bundled_sidecar_path(app).is_some() { + "bundled".to_string() + } else { + "source".to_string() + } +} + +pub(crate) fn find_free_port() -> Result { + for _ in 0..20 { + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|error| format!("failed to allocate port: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("failed to inspect allocated port: {error}"))? + .port(); + drop(listener); + if !RESERVED_PORTS.contains(&port) { + return Ok(port); + } + } + Err( + "failed to find a free port that does not conflict with other LyraNote services" + .to_string(), + ) +} + +pub(crate) fn sidecar_command( + app: &AppHandle, + api_dir: &Path, +) -> Result<(Command, String, String), String> { + if let Some(path) = bundled_sidecar_path(app) { + if is_real_sidecar_binary(&path) { + let path_string = path.display().to_string(); + return Ok((Command::new(path), "bundled".to_string(), path_string)); + } + } + let venv_python = api_dir.join(".venv/bin/python"); + if venv_python.exists() { + let path_string = venv_python.display().to_string(); + Ok((Command::new(venv_python), "source".to_string(), path_string)) + } else { + Ok(( + Command::new("python3"), + "source".to_string(), + "python3".to_string(), + )) + } +} + +pub(crate) fn api_dir() -> Result { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../api") + .canonicalize() + .map_err(|error| format!("failed to locate apps/api: {error}")) +} + +pub(crate) fn runtime_environment_probe( + app: &AppHandle, + runtime_mode: Option, +) -> RuntimeEnvironmentProbe { + let mode = runtime_mode.unwrap_or_else(|| detect_runtime_mode(app)); + let state_dir = resolved_state_dir(app); + let log_dir = resolved_log_dir(app); + RuntimeEnvironmentProbe { + runtime_mode: mode, + api_dir: api_dir() + .map(|path| path.display().to_string()) + .unwrap_or_else(|error| format!("unavailable: {error}")), + resource_dir: app + .path() + .resource_dir() + .ok() + .map(|path| path.display().to_string()), + state_dir: state_dir.display().to_string(), + log_dir: log_dir.display().to_string(), + sidecar_path: bundled_sidecar_path(app).map(|path| path.display().to_string()), + } +} + +fn authenticated_get(runtime: &DesktopRuntime, route: &str) -> Result { + let session = hydrate_session()?; + let token = session + .access_token + .ok_or_else(|| "desktop session is unavailable".to_string())?; + let status = runtime.status(); + if status.api_base_url.is_empty() { + return Err("desktop runtime API base URL is unavailable".to_string()); + } + let endpoint = format!("{}{}", status.api_base_url.trim_end_matches('/'), route); + get_json(&endpoint, &token) +} + +fn post_desktop_import(runtime: &DesktopRuntime, route: &str, path: &str) -> Result<(), String> { + let session = hydrate_session()?; + let token = session + .access_token + .ok_or_else(|| "desktop session is unavailable".to_string())?; + let status = runtime.status(); + if status.api_base_url.is_empty() { + return Err("desktop runtime API base URL is unavailable".to_string()); + } + let endpoint = format!("{}{}", status.api_base_url.trim_end_matches('/'), route); + let body = serde_json::json!({ "path": path }).to_string(); + let response = post_json(&endpoint, &token, &body)?; + if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") { + return Ok(()); + } + Err(format!( + "watch import returned unexpected response: {response}" + )) +} + +fn post_json(url: &str, token: &str, body: &str) -> Result { + let without_scheme = url + .strip_prefix("http://") + .ok_or_else(|| format!("unsupported URL: {url}"))?; + let (host_and_port, path) = without_scheme + .split_once('/') + .ok_or_else(|| format!("invalid URL: {url}"))?; + let address = host_and_port + .parse::() + .map_err(|error| format!("invalid URL '{url}': {error}"))?; + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)) + .map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + let request = format!( + "POST /{path} HTTP/1.1\r\nHost: {host_and_port}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + stream + .write_all(request.as_bytes()) + .map_err(|error| error.to_string())?; + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .map_err(|error| error.to_string())?; + Ok(response.lines().next().unwrap_or_default().to_string()) +} + +fn get_json(url: &str, token: &str) -> Result { + let without_scheme = url + .strip_prefix("http://") + .ok_or_else(|| format!("unsupported URL: {url}"))?; + let (host_and_port, path) = without_scheme + .split_once('/') + .ok_or_else(|| format!("invalid URL: {url}"))?; + let address = host_and_port + .parse::() + .map_err(|error| format!("invalid URL '{url}': {error}"))?; + let mut stream = TcpStream::connect_timeout(&address, Duration::from_secs(2)) + .map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + let request = format!( + "GET /{path} HTTP/1.1\r\nHost: {host_and_port}\r\nAuthorization: Bearer {token}\r\nAccept: application/json\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .map_err(|error| error.to_string())?; + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .map_err(|error| error.to_string())?; + let (head, body) = response + .split_once("\r\n\r\n") + .ok_or_else(|| "malformed HTTP response".to_string())?; + if !(head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200")) { + return Err(format!( + "request failed: {}", + head.lines().next().unwrap_or(head) + )); + } + Ok(body.to_string()) +} + +fn bundled_sidecar_path(app: &AppHandle) -> Option { + if let Ok(binary_path) = std::env::var("LYRANOTE_DESKTOP_BUNDLED_API_PATH") { + let bundled = PathBuf::from(binary_path); + if bundled.exists() { + return Some(bundled); + } + } + + let resource_dir = app.path().resource_dir().ok()?; + let exact = resource_dir.join(bundled_sidecar_name()); + if exact.exists() { + return Some(exact); + } + + let with_target = resource_dir.join(format!( + "{}-{}", + bundled_sidecar_name(), + current_target_triple() + )); + if with_target.exists() { + return Some(with_target); + } + + None +} + +fn is_real_sidecar_binary(path: &Path) -> bool { + const MIN_REAL_BINARY_SIZE: u64 = 4096; + match std::fs::metadata(path) { + Ok(meta) => meta.len() >= MIN_REAL_BINARY_SIZE, + Err(_) => false, + } +} + +fn bundled_sidecar_name() -> &'static str { + "lyranote-api-desktop" +} + +fn current_target_triple() -> &'static str { + if cfg!(all(target_arch = "aarch64", target_os = "macos")) { + "aarch64-apple-darwin" + } else if cfg!(all(target_arch = "x86_64", target_os = "macos")) { + "x86_64-apple-darwin" + } else if cfg!(all(target_arch = "x86_64", target_os = "linux")) { + "x86_64-unknown-linux-gnu" + } else if cfg!(all(target_arch = "aarch64", target_os = "linux")) { + "aarch64-unknown-linux-gnu" + } else if cfg!(all(target_arch = "x86_64", target_os = "windows")) { + "x86_64-pc-windows-msvc" + } else { + "unknown-target" + } +} + +fn newest_log_file(path: &Path) -> Option { + let mut files = std::fs::read_dir(path) + .ok()? + .filter_map(|entry| entry.ok().map(|value| value.path())) + .filter(|candidate| candidate.is_file()) + .collect::>(); + files.sort(); + files.pop() +} diff --git a/apps/desktop/src-tauri/src/runtime/state.rs b/apps/desktop/src-tauri/src/runtime/state.rs new file mode 100644 index 0000000..402911f --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime/state.rs @@ -0,0 +1,153 @@ +use super::sidecar::default_log_dir; +use crate::shared::{RuntimeStatus, WatchFolderRegistration, WatcherDiagnostics}; +use notify::RecommendedWatcher; +use std::{ + collections::HashMap, + process::Child, + sync::{Arc, Mutex}, + time::Instant, +}; + +#[derive(Clone)] +pub struct DesktopRuntime { + pub(crate) inner: Arc>, +} + +#[derive(Clone)] +pub struct WatchManager { + pub(crate) inner: Arc>, + pub(crate) pending_paths: Arc>>, +} + +pub(crate) struct RuntimeInner { + pub(crate) child: Option, + pub(crate) status: RuntimeStatus, + pub(crate) auto_restart_attempts: u32, +} + +pub(crate) struct WatchManagerInner { + pub(crate) watcher: Option, + pub(crate) worker_started: bool, + pub(crate) paused: bool, + pub(crate) watched_folders: Vec, + pub(crate) last_error: Option, +} + +impl Default for DesktopRuntime { + fn default() -> Self { + let mut status = RuntimeStatus::default(); + status.log_path = default_log_dir().display().to_string(); + Self { + inner: Arc::new(Mutex::new(RuntimeInner { + child: None, + status, + auto_restart_attempts: 0, + })), + } + } +} + +impl Default for WatchManager { + fn default() -> Self { + Self { + inner: Arc::new(Mutex::new(WatchManagerInner { + watcher: None, + worker_started: false, + paused: false, + watched_folders: Vec::new(), + last_error: None, + })), + pending_paths: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl DesktopRuntime { + pub fn status(&self) -> RuntimeStatus { + self.inner + .lock() + .expect("runtime mutex poisoned") + .status + .clone() + } +} + +impl WatchManager { + pub fn is_paused(&self) -> bool { + self.inner.lock().map(|inner| inner.paused).unwrap_or(false) + } + + pub fn diagnostics_snapshot(&self) -> WatcherDiagnostics { + let (watcher_count, paused, watched_paths, last_error) = self + .inner + .lock() + .map(|inner| { + ( + inner.watched_folders.len(), + inner.paused, + inner + .watched_folders + .iter() + .map(|folder| folder.path.clone()) + .collect::>(), + inner.last_error.clone(), + ) + }) + .unwrap_or_else(|_| { + ( + 0, + false, + Vec::new(), + Some("watch manager mutex poisoned".to_string()), + ) + }); + let pending_paths_count = self + .pending_paths + .lock() + .map(|pending| pending.len()) + .unwrap_or(0); + + WatcherDiagnostics { + watcher_count, + paused, + watched_paths, + pending_paths_count, + last_error, + } + } +} + +#[cfg(test)] +mod tests { + use super::WatchManager; + use crate::shared::WatchFolderRegistration; + use std::time::Instant; + + #[test] + fn reports_watcher_diagnostics_snapshot() { + let manager = WatchManager::default(); + { + let mut inner = manager.inner.lock().expect("watch manager lock"); + inner.last_error = Some("watcher lost".into()); + inner.paused = true; + inner.watched_folders = vec![WatchFolderRegistration { + id: "folder-1".into(), + path: "/tmp/notes".into(), + name: "notes".into(), + created_at: "2026-04-18T00:00:00Z".into(), + }]; + } + { + let mut pending = manager.pending_paths.lock().expect("pending paths lock"); + pending.insert("/tmp/notes/demo.pdf".into(), Instant::now()); + } + + let snapshot = manager.diagnostics_snapshot(); + + assert_eq!(snapshot.watcher_count, 1); + assert!(snapshot.paused); + assert_eq!(snapshot.pending_paths_count, 1); + assert_eq!(snapshot.watched_paths, vec!["/tmp/notes".to_string()]); + assert_eq!(snapshot.last_error.as_deref(), Some("watcher lost")); + } +} diff --git a/apps/desktop/src-tauri/src/runtime/supervisor.rs b/apps/desktop/src-tauri/src/runtime/supervisor.rs new file mode 100644 index 0000000..c64aef0 --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime/supervisor.rs @@ -0,0 +1,397 @@ +use crate::shared::{DesktopRuntimeState, RuntimeStatus}; +use std::{ + io::{BufRead, BufReader, Read}, + process::Stdio, + thread, + time::{Duration, Instant}, +}; +use tauri::{AppHandle, Emitter, Runtime}; + +use super::{ + health::{map_sidecar_event_name, parse_sidecar_event, ping_health}, + sidecar::{ + api_dir, detect_runtime_mode, find_free_port, now_iso_string, resolved_log_dir, + resolved_state_dir, sidecar_command, + }, + state::DesktopRuntime, +}; + +const RUNTIME_EVENT_NAME: &str = "runtime://state"; +const MAX_AUTO_RESTART_ATTEMPTS: u32 = 1; +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); +const HEARTBEAT_FAILURE_THRESHOLD: u32 = 2; + +impl DesktopRuntime { + pub fn ensure_started(&self, app: &AppHandle) -> Result { + { + let inner = self + .inner + .lock() + .map_err(|_| "runtime mutex poisoned".to_string())?; + if matches!( + inner.status.state, + DesktopRuntimeState::Starting | DesktopRuntimeState::Ready + ) { + return Ok(inner.status.clone()); + } + } + + self.start(app.clone(), false)?; + Ok(self.status()) + } + + pub fn restart(&self, app: &AppHandle) -> Result { + self.stop()?; + self.start(app.clone(), true)?; + Ok(self.status()) + } + + pub fn stop(&self) -> Result<(), String> { + let mut inner = self + .inner + .lock() + .map_err(|_| "runtime mutex poisoned".to_string())?; + if let Some(mut child) = inner.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + inner.auto_restart_attempts = 0; + inner.status = RuntimeStatus { + state: DesktopRuntimeState::Stopped, + pid: None, + last_error: None, + version: None, + last_healthcheck_at: None, + last_heartbeat_at: None, + ..inner.status.clone() + }; + Ok(()) + } + + pub fn set_watcher_count(&self, app: &AppHandle, watcher_count: usize) { + let mut status = self.status(); + status.watcher_count = watcher_count; + self.update_status(app, status); + } + + pub fn set_watchers_paused(&self, app: &AppHandle, watchers_paused: bool) { + let mut status = self.status(); + status.watchers_paused = watchers_paused; + self.update_status(app, status); + } + + pub(crate) fn start( + &self, + app: AppHandle, + is_restart: bool, + ) -> Result<(), String> { + let port = find_free_port()?; + let health_url = format!("http://127.0.0.1:{port}/health"); + let api_base_url = format!("http://127.0.0.1:{port}/api/v1"); + let runtime_mode = detect_runtime_mode(&app); + let log_path = resolved_log_dir(&app).display().to_string(); + let state_dir = resolved_state_dir(&app).display().to_string(); + + let mut next_status = self.status(); + next_status.state = DesktopRuntimeState::Starting; + next_status.mode = runtime_mode.clone(); + next_status.health_url = health_url.clone(); + next_status.api_base_url = api_base_url.clone(); + next_status.pid = None; + next_status.version = None; + next_status.last_error = None; + next_status.last_healthcheck_at = None; + next_status.last_heartbeat_at = None; + next_status.log_path = log_path.clone(); + next_status.state_dir = state_dir.clone(); + self.update_status(&app, next_status); + + let api_dir = api_dir()?; + let (mut command, mode, sidecar_path) = sidecar_command(&app, &api_dir)?; + if mode == "bundled" { + command + .arg("--host") + .arg("127.0.0.1") + .arg("--port") + .arg(port.to_string()); + } else { + command + .args([ + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + &port.to_string(), + ]) + .current_dir(&api_dir) + .env("PYTHONPATH", "."); + } + command + .env("RUNTIME_PROFILE", "desktop") + .env("DESKTOP_STDOUT_EVENTS", "true") + .env("DESKTOP_STATE_DIR_OVERRIDE", &state_dir) + .env("LOGS_DIR_OVERRIDE", &log_path) + .env("MEMORY_MODE", "desktop") + .env("MONITORING_ENABLED", "false") + .env( + "CORS_ORIGINS", + "http://tauri.localhost,tauri://localhost,http://localhost:1420,http://127.0.0.1:1420", + ) + .env("FRONTEND_URL", "http://tauri.localhost") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|error| format!("failed to spawn sidecar: {error}"))?; + let pid = child.id(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + { + let mut inner = self + .inner + .lock() + .map_err(|_| "runtime mutex poisoned".to_string())?; + inner.status.pid = Some(pid); + inner.status.sidecar_path = Some(sidecar_path); + if is_restart { + inner.status.restart_count += 1; + inner.status.last_restart_at = Some(now_iso_string()); + } + inner.child = Some(child); + } + self.emit_current_status(&app); + + if let Some(stdout) = stdout { + spawn_output_reader(self.clone(), app.clone(), stdout, false); + } + if let Some(stderr) = stderr { + spawn_output_reader(self.clone(), app.clone(), stderr, true); + } + + spawn_health_monitor(self.clone(), app.clone(), health_url.clone()); + spawn_runtime_heartbeat(self.clone(), app.clone(), health_url.clone()); + spawn_exit_monitor(self.clone(), app.clone()); + + Ok(()) + } + + fn emit_current_status(&self, app: &AppHandle) { + let _ = app.emit(RUNTIME_EVENT_NAME, self.status()); + } + + fn update_status(&self, app: &AppHandle, status: RuntimeStatus) { + if let Ok(mut inner) = self.inner.lock() { + inner.status = status; + } + self.emit_current_status(app); + } + + fn set_ready(&self, app: &AppHandle, version: Option) { + let mut status = self.status(); + status.state = DesktopRuntimeState::Ready; + status.version = version.or(status.version); + status.last_error = None; + status.last_healthcheck_at = Some(now_iso_string()); + status.last_heartbeat_at = Some(now_iso_string()); + self.update_status(app, status); + if let Ok(mut inner) = self.inner.lock() { + inner.auto_restart_attempts = 0; + } + } + + fn set_degraded(&self, app: &AppHandle, message: String) { + let mut status = self.status(); + status.state = DesktopRuntimeState::Degraded; + status.last_error = Some(message); + status.last_healthcheck_at = Some(now_iso_string()); + self.update_status(app, status); + } + + fn record_heartbeat(&self, app: &AppHandle) { + let mut status = self.status(); + status.last_healthcheck_at = Some(now_iso_string()); + status.last_heartbeat_at = status.last_healthcheck_at.clone(); + self.update_status(app, status); + } + + fn record_exit_reason(&self, app: &AppHandle, reason: String) { + let mut status = self.status(); + status.last_exit_reason = Some(reason); + self.update_status(app, status); + } + + fn capture_version(&self, app: &AppHandle, version: Option) { + if version.is_none() { + return; + } + let mut status = self.status(); + status.version = version; + self.update_status(app, status); + } +} + +fn spawn_output_reader( + runtime: DesktopRuntime, + app: AppHandle, + reader: R, + is_stderr: bool, +) { + thread::spawn(move || { + let prefix = if is_stderr { "stderr" } else { "stdout" }; + for line_result in BufReader::new(reader).lines() { + let Ok(line) = line_result else { continue }; + if line.trim().is_empty() { + continue; + } + println!("[desktop-runtime:{prefix}] {line}"); + if let Some(event) = parse_sidecar_event(&line) { + if event.event_type == "runtime.ready" { + let version = event + .payload + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + runtime.capture_version(&app, version); + } + if let Some(event_name) = map_sidecar_event_name(&event.event_type) { + let _ = app.emit(event_name, &event); + } + } + } + }); +} + +fn spawn_health_monitor( + runtime: DesktopRuntime, + app: AppHandle, + health_url: String, +) { + thread::spawn(move || { + let started_at = Instant::now(); + while started_at.elapsed() < Duration::from_secs(25) { + if ping_health(&health_url).is_ok() { + runtime.set_ready(&app, None); + return; + } + thread::sleep(Duration::from_millis(400)); + } + runtime.set_degraded( + &app, + "Timed out waiting for desktop sidecar health check.".to_string(), + ); + }); +} + +fn spawn_runtime_heartbeat( + runtime: DesktopRuntime, + app: AppHandle, + health_url: String, +) { + thread::spawn(move || { + let mut failure_count = 0_u32; + loop { + thread::sleep(HEARTBEAT_INTERVAL); + let status = runtime.status(); + if status.pid.is_none() || status.health_url != health_url { + return; + } + + match ping_health(&health_url) { + Ok(_) => { + failure_count = 0; + runtime.record_heartbeat(&app); + } + Err(error) => { + failure_count += 1; + if failure_count >= HEARTBEAT_FAILURE_THRESHOLD { + runtime.set_degraded( + &app, + format!("Desktop runtime heartbeat failed: {error}"), + ); + } + } + } + } + }); +} + +fn spawn_exit_monitor(runtime: DesktopRuntime, app: AppHandle) { + thread::spawn(move || loop { + let mut exit_message = None; + let mut restart_delay = None; + { + let mut inner = match runtime.inner.lock() { + Ok(inner) => inner, + Err(_) => return, + }; + let Some(child) = inner.child.as_mut() else { + return; + }; + match child.try_wait() { + Ok(Some(status)) => { + exit_message = Some(format!("Desktop sidecar exited with status {status}")); + inner.child = None; + if inner.auto_restart_attempts < MAX_AUTO_RESTART_ATTEMPTS { + inner.auto_restart_attempts += 1; + restart_delay = Some(compute_restart_backoff(inner.auto_restart_attempts)); + } + } + Ok(None) => {} + Err(error) => { + exit_message = Some(format!("Failed to monitor desktop sidecar: {error}")); + inner.child = None; + } + } + } + + if let Some(message) = exit_message { + runtime.record_exit_reason(&app, message.clone()); + if let Some(delay) = restart_delay { + runtime.set_degraded( + &app, + format!( + "{message}. LyraNote 将在 {:.1}s 后尝试恢复。", + delay.as_secs_f32() + ), + ); + thread::sleep(delay); + if let Err(error) = runtime.start(app.clone(), true) { + runtime.record_exit_reason(&app, error.clone()); + runtime + .set_degraded(&app, format!("Desktop runtime failed to restart: {error}")); + } + } else { + runtime.set_degraded(&app, message); + } + return; + } + + thread::sleep(Duration::from_millis(800)); + }); +} + +fn compute_restart_backoff(attempt: u32) -> Duration { + let capped_attempt = attempt.min(4); + let base_ms = 1_000_u64; + Duration::from_millis( + base_ms.saturating_mul(2_u64.saturating_pow(capped_attempt.saturating_sub(1))), + ) +} + +#[cfg(test)] +mod tests { + use super::compute_restart_backoff; + use std::time::Duration; + + #[test] + fn computes_exponential_restart_backoff() { + assert_eq!(compute_restart_backoff(1), Duration::from_secs(1)); + assert_eq!(compute_restart_backoff(2), Duration::from_secs(2)); + assert_eq!(compute_restart_backoff(3), Duration::from_secs(4)); + assert_eq!(compute_restart_backoff(10), Duration::from_secs(8)); + } +} diff --git a/apps/desktop/src-tauri/src/runtime/watchers.rs b/apps/desktop/src-tauri/src/runtime/watchers.rs new file mode 100644 index 0000000..43fd0aa --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime/watchers.rs @@ -0,0 +1,279 @@ +use crate::{ + runtime::{now_iso_string, sidecar::resolved_state_dir, DesktopRuntime}, + shared::{SidecarEvent, WatchFolderRegistration}, +}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use std::{ + collections::HashMap, + fs, + path::Path, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; +use tauri::{AppHandle, Emitter, Runtime}; + +use super::state::WatchManager; + +const IMPORT_EVENT_NAME: &str = "import://result"; +const WATCHER_STATE_FILENAME: &str = "watchers-config.json"; + +impl WatchManager { + pub fn hydrate(&self, app: &AppHandle, runtime: &DesktopRuntime) { + let paused = load_paused_state(&watcher_state_path(app)).unwrap_or(false); + if let Ok(mut inner) = self.inner.lock() { + inner.paused = paused; + } + runtime.set_watchers_paused(app, paused); + } + + pub fn sync_folders( + &self, + app: &AppHandle, + runtime: DesktopRuntime, + folders: Vec, + ) -> Result<(), String> { + self.ensure_worker_started(app.clone(), runtime.clone()); + let pending = self.pending_paths.clone(); + let folder_snapshot = folders.clone(); + let watcher = notify::recommended_watcher(move |result: notify::Result| { + let Ok(event) = result else { return }; + for path in event.paths { + if !should_watch_path(&path) { + continue; + } + if let Ok(normalized) = normalize_path(&path) { + if let Ok(mut paths) = pending.lock() { + paths.insert(normalized, Instant::now()); + } + } + } + }) + .map_err(|error| format!("failed to create watch manager: {error}"))?; + + let watcher_count = folders.len(); + if let Err(error) = self.replace_watcher(watcher, folder_snapshot) { + if let Ok(mut inner) = self.inner.lock() { + inner.last_error = Some(error.clone()); + } + return Err(error); + } + runtime.set_watcher_count(app, watcher_count); + runtime.set_watchers_paused(app, self.is_paused()); + Ok(()) + } + + pub fn set_paused( + &self, + app: &AppHandle, + runtime: DesktopRuntime, + paused: bool, + ) -> Result { + { + let mut inner = self + .inner + .lock() + .map_err(|_| "watch manager mutex poisoned".to_string())?; + inner.paused = paused; + } + persist_paused_state(&watcher_state_path(app), paused)?; + runtime.set_watchers_paused(app, paused); + Ok(paused) + } + + pub fn toggle_paused( + &self, + app: &AppHandle, + runtime: DesktopRuntime, + ) -> Result { + let next = !self.is_paused(); + self.set_paused(app, runtime, next) + } + + fn replace_watcher( + &self, + mut watcher: RecommendedWatcher, + folders: Vec, + ) -> Result<(), String> { + for folder in &folders { + watcher + .watch(Path::new(&folder.path), RecursiveMode::Recursive) + .map_err(|error| format!("failed to watch '{}': {error}", folder.path))?; + } + + let mut inner = self + .inner + .lock() + .map_err(|_| "watch manager mutex poisoned".to_string())?; + inner.watcher = Some(watcher); + inner.last_error = None; + inner.watched_folders = folders; + Ok(()) + } + + fn ensure_worker_started(&self, app: AppHandle, runtime: DesktopRuntime) { + let watch_inner = self.inner.clone(); + let should_start = { + let mut inner = match self.inner.lock() { + Ok(inner) => inner, + Err(_) => return, + }; + if inner.worker_started { + false + } else { + inner.worker_started = true; + true + } + }; + + if !should_start { + return; + } + + let pending_paths = self.pending_paths.clone(); + thread::spawn(move || loop { + let paused = watch_inner + .lock() + .map(|inner| inner.paused) + .unwrap_or(false); + if paused { + thread::sleep(Duration::from_millis(200)); + continue; + } + let due_paths = take_due_paths(&pending_paths, Duration::from_millis(500)); + for path in due_paths { + if let Err(error) = super::sidecar::post_watch_import(&runtime, &path) { + let _ = app.emit( + IMPORT_EVENT_NAME, + SidecarEvent { + event_type: "import.failed".to_string(), + payload: serde_json::json!({ + "path": path, + "state": "failed", + "error": error, + }), + occurred_at: now_iso_string(), + }, + ); + } + } + thread::sleep(Duration::from_millis(200)); + }); + } +} + +fn watcher_state_path(app: &AppHandle) -> std::path::PathBuf { + resolved_state_dir(app).join(WATCHER_STATE_FILENAME) +} + +fn load_paused_state(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read watcher state: {error}"))?; + let payload: serde_json::Value = + serde_json::from_str(&raw).map_err(|error| format!("invalid watcher state: {error}"))?; + Ok(payload + .get("paused") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false)) +} + +fn persist_paused_state(path: &Path, paused: bool) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create watcher state dir: {error}"))?; + } + let raw = serde_json::json!({ "paused": paused }).to_string(); + fs::write(path, raw).map_err(|error| format!("failed to persist watcher state: {error}")) +} + +pub(crate) fn take_due_paths( + pending_paths: &Arc>>, + debounce_window: Duration, +) -> Vec { + let mut paths = match pending_paths.lock() { + Ok(paths) => paths, + Err(_) => return Vec::new(), + }; + let now = Instant::now(); + let due: Vec = paths + .iter() + .filter_map(|(path, queued_at)| { + if now.duration_since(*queued_at) >= debounce_window { + Some(path.clone()) + } else { + None + } + }) + .collect(); + for path in &due { + paths.remove(path); + } + due +} + +pub(crate) fn should_watch_path(path: &Path) -> bool { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + matches!( + extension.as_deref(), + Some("pdf") | Some("md") | Some("txt") | Some("docx") + ) +} + +fn normalize_path(path: &Path) -> Result { + let absolute = path + .canonicalize() + .map_err(|error| format!("failed to resolve path '{}': {error}", path.display()))?; + Ok(absolute.display().to_string()) +} + +#[cfg(test)] +mod tests { + use super::{load_paused_state, persist_paused_state, should_watch_path, take_due_paths}; + use std::{ + collections::HashMap, + fs, + path::Path, + sync::{Arc, Mutex}, + time::{Duration, Instant}, + }; + + #[test] + fn filters_supported_watch_paths() { + assert!(should_watch_path(Path::new("/tmp/demo.pdf"))); + assert!(should_watch_path(Path::new("/tmp/demo.md"))); + assert!(!should_watch_path(Path::new("/tmp/demo.png"))); + } + + #[test] + fn drains_only_due_debounce_entries() { + let pending = Arc::new(Mutex::new(HashMap::from([ + ( + "ready".to_string(), + Instant::now() - Duration::from_millis(900), + ), + ("waiting".to_string(), Instant::now()), + ]))); + + let due = take_due_paths(&pending, Duration::from_millis(500)); + + assert_eq!(due, vec!["ready".to_string()]); + let remaining = pending.lock().expect("pending paths lock"); + assert!(remaining.contains_key("waiting")); + assert!(!remaining.contains_key("ready")); + } + + #[test] + fn persists_and_loads_paused_state() { + let path = std::env::temp_dir().join("lyranote-watchers-state-test.json"); + persist_paused_state(&path, true).unwrap(); + assert!(load_paused_state(&path).unwrap()); + + let _ = fs::remove_file(path); + } +} diff --git a/apps/desktop/src-tauri/src/security/keychain.rs b/apps/desktop/src-tauri/src/security/keychain.rs new file mode 100644 index 0000000..40abac3 --- /dev/null +++ b/apps/desktop/src-tauri/src/security/keychain.rs @@ -0,0 +1,79 @@ +use std::process::Command; + +pub fn read_generic_password(service: &str, account: &str) -> Result, String> { + let output = Command::new("security") + .args(["find-generic-password", "-s", service, "-a", account, "-w"]) + .output() + .map_err(|error| format!("failed to invoke security: {error}"))?; + + if output.status.success() { + let password = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if password.is_empty() { + return Ok(None); + } + return Ok(Some(password)); + } + + if stderr_contains(&output.stderr, "could not be found") { + return Ok(None); + } + + Err(stderr_or_default( + &output.stderr, + "failed to read stored keychain entry", + )) +} + +pub fn write_generic_password(service: &str, account: &str, value: &str) -> Result<(), String> { + let output = Command::new("security") + .args([ + "add-generic-password", + "-U", + "-s", + service, + "-a", + account, + "-w", + value, + ]) + .output() + .map_err(|error| format!("failed to invoke security: {error}"))?; + + if output.status.success() { + return Ok(()); + } + + Err(stderr_or_default( + &output.stderr, + "failed to persist keychain entry", + )) +} + +pub fn delete_generic_password(service: &str, account: &str) -> Result<(), String> { + let output = Command::new("security") + .args(["delete-generic-password", "-s", service, "-a", account]) + .output() + .map_err(|error| format!("failed to invoke security: {error}"))?; + + if output.status.success() || stderr_contains(&output.stderr, "could not be found") { + return Ok(()); + } + + Err(stderr_or_default( + &output.stderr, + "failed to clear stored keychain entry", + )) +} + +pub fn stderr_contains(stderr: &[u8], needle: &str) -> bool { + String::from_utf8_lossy(stderr).contains(needle) +} + +pub fn stderr_or_default(stderr: &[u8], default: &str) -> String { + let content = String::from_utf8_lossy(stderr).trim().to_string(); + if content.is_empty() { + default.to_string() + } else { + content + } +} diff --git a/apps/desktop/src-tauri/src/security/mod.rs b/apps/desktop/src-tauri/src/security/mod.rs new file mode 100644 index 0000000..311936c --- /dev/null +++ b/apps/desktop/src-tauri/src/security/mod.rs @@ -0,0 +1,6 @@ +pub mod keychain; +pub mod secrets; +pub mod session; + +pub use secrets::{delete_secret, get_secret, list_secret_keys, store_secret}; +pub use session::{clear_session, hydrate_session, store_session}; diff --git a/apps/desktop/src-tauri/src/security/secrets.rs b/apps/desktop/src-tauri/src/security/secrets.rs new file mode 100644 index 0000000..237929c --- /dev/null +++ b/apps/desktop/src-tauri/src/security/secrets.rs @@ -0,0 +1,178 @@ +use crate::{ + runtime::{now_iso_string, sidecar::resolved_state_dir}, + shared::DesktopSecretKey, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, +}; +use tauri::{AppHandle, Runtime}; + +use super::keychain::{delete_generic_password, read_generic_password, write_generic_password}; + +const SECRET_KEYCHAIN_SERVICE: &str = "com.lyranote.desktop.secret"; +const SECRET_INDEX_FILENAME: &str = "secure-secrets-index.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct SecretIndex { + items: Vec, +} + +pub fn store_secret( + app: &AppHandle, + key: String, + value: String, +) -> Result { + let key = validate_secret_key(&key)?; + write_generic_password(SECRET_KEYCHAIN_SERVICE, &key, &value)?; + + let mut index = load_secret_index(&secret_index_path(app))?; + let updated_at = now_iso_string(); + let item = DesktopSecretKey { + key: key.clone(), + updated_at, + }; + upsert_secret_key(&mut index.items, item.clone()); + persist_secret_index(&secret_index_path(app), &index)?; + Ok(item) +} + +pub fn get_secret(key: String) -> Result, String> { + let key = validate_secret_key(&key)?; + read_generic_password(SECRET_KEYCHAIN_SERVICE, &key) +} + +pub fn delete_secret(app: &AppHandle, key: String) -> Result<(), String> { + let key = validate_secret_key(&key)?; + delete_generic_password(SECRET_KEYCHAIN_SERVICE, &key)?; + let index_path = secret_index_path(app); + let mut index = load_secret_index(&index_path)?; + index.items.retain(|item| item.key != key); + persist_secret_index(&index_path, &index)?; + Ok(()) +} + +pub fn list_secret_keys(app: &AppHandle) -> Result, String> { + let index = load_secret_index(&secret_index_path(app))?; + Ok(index.items) +} + +fn secret_index_path(app: &AppHandle) -> PathBuf { + resolved_state_dir(app).join(SECRET_INDEX_FILENAME) +} + +fn upsert_secret_key(items: &mut Vec, next: DesktopSecretKey) { + if let Some(existing) = items.iter_mut().find(|item| item.key == next.key) { + *existing = next; + } else { + items.push(next); + } + items.sort_by(|left, right| left.key.cmp(&right.key)); +} + +fn load_secret_index(path: &Path) -> Result { + if !path.exists() { + return Ok(SecretIndex::default()); + } + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read secure secret index: {error}"))?; + serde_json::from_str(&raw).map_err(|error| format!("invalid secure secret index: {error}")) +} + +fn persist_secret_index(path: &Path, index: &SecretIndex) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create secure secret state dir: {error}"))?; + } + let raw = serde_json::to_string_pretty(index) + .map_err(|error| format!("failed to encode secure secret index: {error}"))?; + fs::write(path, raw).map_err(|error| format!("failed to persist secure secret index: {error}")) +} + +pub(crate) fn validate_secret_key(value: &str) -> Result { + let candidate = value.trim(); + if candidate.is_empty() { + return Err("secret key cannot be empty".to_string()); + } + if candidate.len() > 80 { + return Err("secret key is too long".to_string()); + } + if candidate + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return Ok(candidate.to_string()); + } + Err("secret key may only contain letters, numbers, '.', '-' and '_'".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_path(name: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!("lyranote-{name}-{suffix}.json")) + } + + #[test] + fn validates_secret_keys() { + assert_eq!( + validate_secret_key("device.identity_1").unwrap(), + "device.identity_1".to_string() + ); + assert!(validate_secret_key("bad key").is_err()); + assert!(validate_secret_key("").is_err()); + } + + #[test] + fn persists_secret_index_in_sorted_order() { + let path = temp_path("secret-index"); + let index = SecretIndex { + items: vec![ + DesktopSecretKey { + key: "beta".to_string(), + updated_at: "2".to_string(), + }, + DesktopSecretKey { + key: "alpha".to_string(), + updated_at: "1".to_string(), + }, + ], + }; + persist_secret_index(&path, &index).unwrap(); + + let mut loaded = load_secret_index(&path).unwrap(); + upsert_secret_key( + &mut loaded.items, + DesktopSecretKey { + key: "gamma".to_string(), + updated_at: "3".to_string(), + }, + ); + upsert_secret_key( + &mut loaded.items, + DesktopSecretKey { + key: "alpha".to_string(), + updated_at: "4".to_string(), + }, + ); + + assert_eq!( + loaded + .items + .iter() + .map(|item| item.key.as_str()) + .collect::>(), + vec!["alpha", "beta", "gamma"] + ); + assert_eq!(loaded.items[0].updated_at, "4"); + + let _ = fs::remove_file(path); + } +} diff --git a/apps/desktop/src-tauri/src/security/session.rs b/apps/desktop/src-tauri/src/security/session.rs new file mode 100644 index 0000000..6fd5207 --- /dev/null +++ b/apps/desktop/src-tauri/src/security/session.rs @@ -0,0 +1,45 @@ +use crate::shared::{SecureSession, SecureSessionRecord}; + +use super::keychain::{delete_generic_password, read_generic_password, write_generic_password}; + +const KEYCHAIN_SERVICE: &str = "com.lyranote.desktop.session"; +const KEYCHAIN_ACCOUNT: &str = "default"; + +pub fn hydrate_session() -> Result { + match read_keychain_secret()? { + Some(raw) => { + let parsed: SecureSessionRecord = serde_json::from_str(&raw) + .map_err(|error| format!("invalid stored session: {error}"))?; + Ok(SecureSession { + has_session: true, + access_token: Some(parsed.access_token), + user_id: parsed.user_id, + username: parsed.username, + user: parsed.user, + }) + } + None => Ok(SecureSession { + has_session: false, + access_token: None, + user_id: None, + username: None, + user: None, + }), + } +} + +pub fn store_session(payload: SecureSessionRecord) -> Result { + let raw = serde_json::to_string(&payload) + .map_err(|error| format!("failed to encode session: {error}"))?; + write_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, &raw)?; + + hydrate_session() +} + +pub fn clear_session() -> Result<(), String> { + delete_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) +} + +fn read_keychain_secret() -> Result, String> { + read_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) +} diff --git a/apps/desktop/src-tauri/src/shared/mod.rs b/apps/desktop/src-tauri/src/shared/mod.rs new file mode 100644 index 0000000..26535e7 --- /dev/null +++ b/apps/desktop/src-tauri/src/shared/mod.rs @@ -0,0 +1,3 @@ +pub mod types; + +pub use types::*; diff --git a/apps/desktop/src-tauri/src/shared/types.rs b/apps/desktop/src-tauri/src/shared/types.rs new file mode 100644 index 0000000..120ef46 --- /dev/null +++ b/apps/desktop/src-tauri/src/shared/types.rs @@ -0,0 +1,240 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DesktopRuntimeState { + Starting, + Ready, + Degraded, + Stopped, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeStatus { + pub state: DesktopRuntimeState, + pub mode: String, + pub health_url: String, + pub api_base_url: String, + pub pid: Option, + pub version: Option, + pub last_error: Option, + pub last_exit_reason: Option, + pub last_healthcheck_at: Option, + pub last_heartbeat_at: Option, + pub log_path: String, + pub state_dir: String, + pub sidecar_path: Option, + pub restart_count: u32, + pub watcher_count: usize, + pub watchers_paused: bool, + pub last_restart_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SelectedPath { + pub path: String, + pub name: String, + pub is_dir: bool, + pub mime_hint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopNotification { + pub kind: String, + pub title: String, + pub body: String, + #[serde(default)] + pub route: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchFolderRegistration { + pub id: String, + pub path: String, + pub name: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidecarEvent { + #[serde(rename = "type")] + pub event_type: String, + #[serde(default)] + pub payload: Value, + pub occurred_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecureSessionRecord { + pub access_token: String, + pub refresh_token: Option, + pub user_id: Option, + pub username: Option, + pub user: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SecureSession { + pub has_session: bool, + pub access_token: Option, + pub user_id: Option, + pub username: Option, + pub user: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DesktopWindowKind { + Main, + QuickCapture, + Chat, + SourceDetail, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopShortcutConfig { + pub accelerator: String, + pub action: String, + pub enabled: bool, + pub supported: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopNotificationRoute { + pub kind: String, + #[serde(default)] + pub section: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub source_id: Option, + #[serde(default)] + pub window: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopRecentItem { + pub kind: String, + pub title: String, + #[serde(default)] + pub subtitle: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub source_id: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopDiagnosticsBundleMeta { + pub path: String, + pub generated_at: String, + pub log_path: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WatcherDiagnostics { + pub watcher_count: usize, + pub paused: bool, + pub watched_paths: Vec, + pub pending_paths_count: usize, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeEnvironmentProbe { + pub runtime_mode: String, + pub api_dir: String, + pub resource_dir: Option, + pub state_dir: String, + pub log_dir: String, + pub sidecar_path: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DesktopShellEvent { + pub shortcut: DesktopShortcutConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopSecretKey { + pub key: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopFileProbe { + pub path: String, + pub name: String, + pub is_dir: bool, + pub size_bytes: Option, + pub extension: Option, + pub mime_hint: Option, + pub created_at: Option, + pub modified_at: Option, + pub pdf_page_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopHashResult { + pub path: String, + pub algorithm: String, + pub digest: String, + pub bytes_processed: u64, +} + +impl Default for RuntimeStatus { + fn default() -> Self { + Self { + state: DesktopRuntimeState::Stopped, + mode: "source".to_string(), + health_url: String::new(), + api_base_url: String::new(), + pid: None, + version: None, + last_error: None, + last_exit_reason: None, + last_healthcheck_at: None, + last_heartbeat_at: None, + log_path: String::new(), + state_dir: String::new(), + sidecar_path: None, + restart_count: 0, + watcher_count: 0, + watchers_paused: false, + last_restart_at: None, + } + } +} + +impl Default for DesktopShortcutConfig { + fn default() -> Self { + Self { + accelerator: "CmdOrCtrl+Shift+L".to_string(), + action: "quick-capture".to_string(), + enabled: true, + supported: false, + } + } +} + +impl DesktopWindowKind { + pub fn label(&self) -> &'static str { + match self { + Self::Main => "main", + Self::QuickCapture => "quick-capture", + Self::Chat => "chat", + Self::SourceDetail => "source-detail", + } + } + + pub fn title(&self) -> &'static str { + match self { + Self::Main => "LyraNote", + Self::QuickCapture => "Quick Capture", + Self::Chat => "LyraNote Chat", + Self::SourceDetail => "Source Detail", + } + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 33b2dbd..a597461 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,5 +1,5 @@ { - "$schema": "../node_modules/@tauri-apps/cli/schema.json", + "$schema": "https://schema.tauri.app/config/2", "productName": "LyraNote", "version": "0.1.0", "identifier": "com.lyranote.desktop", @@ -10,7 +10,6 @@ "frontendDist": "../dist" }, "app": { - "withGlobalTauri": true, "windows": [ { "title": "LyraNote", @@ -19,23 +18,56 @@ "minWidth": 900, "minHeight": 600, "decorations": false, - "transparent": false, - "titleBarStyle": "Overlay", - "hiddenTitle": true + "transparent": true, + "center": true, + "dragDropEnabled": true } ], "security": { "csp": null }, - "trayIcon": { - "iconPath": "icons/icon.png", - "iconAsTemplate": true, - "menuOnLeftClick": false - } + "macOSPrivateApi": true }, "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, + "externalBin": [ + "binaries/lyranote-api-desktop" + ], + "resources": { + "binaries/lyranote-api-desktop-runtime": "lyranote-api-desktop-runtime" + }, + "fileAssociations": [ + { + "ext": ["pdf"], + "name": "LyraNote PDF", + "description": "Open PDF files in LyraNote", + "mimeType": "application/pdf", + "role": "Editor" + }, + { + "ext": ["md"], + "name": "LyraNote Markdown", + "description": "Open Markdown files in LyraNote", + "mimeType": "text/markdown", + "role": "Editor" + }, + { + "ext": ["txt"], + "name": "LyraNote Text", + "description": "Open text files in LyraNote", + "mimeType": "text/plain", + "role": "Editor" + }, + { + "ext": ["docx"], + "name": "LyraNote Word", + "description": "Open Word documents in LyraNote", + "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "role": "Editor" + } + ], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -45,9 +77,11 @@ ] }, "plugins": { - "notification": {}, - "store": { - "filename": "lyranote-store.json" + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRGMjg0NkUxNDVCMjMyREMKUldUY01ySkY0VVlvVDlIcUlRSFBPZlVIUElQdG9OSmk0OW5obWhiaFFEYjd6Qkd5dkdxUm9kYTAK", + "endpoints": [ + "https://github.com/LinMoQC/LyraNote/releases/latest/download/latest.json" + ] } } } diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css new file mode 100644 index 0000000..577128a --- /dev/null +++ b/apps/desktop/src/App.css @@ -0,0 +1 @@ +/* Moved to src/styles/globals.css */ diff --git a/apps/desktop/src/app.tsx b/apps/desktop/src/app.tsx index 8b57cde..59afb61 100644 --- a/apps/desktop/src/app.tsx +++ b/apps/desktop/src/app.tsx @@ -1,44 +1,517 @@ -import { useEffect, useState } from "react"; -import { useAuthStore } from "@/store/use-auth-store"; -import { MainLayout } from "@/layouts/main-layout"; -import { LoginPage } from "@/pages/login"; -import { getHttpClient } from "@/lib/http-client"; -import { createAuthService } from "@lyranote/api-client"; +import { lazy, memo, Suspense, useEffect, useRef, useState } from "react" +import { createPortal } from "react-dom" +import { AnimatePresence, motion } from "framer-motion" +import { + fileReveal, + listenDesktopWindowRoute, + listenImportResults, + listenJobProgress, + listenRuntimeState, + listenWindowFileDrop, + notificationShow, + runtimeRestart, + runtimeStatus, + watchFoldersSync, +} from "@/lib/desktop-bridge" +import { hydrateDesktopAuthSession } from "@/lib/auth-session" +import { RuntimeStatusScreen } from "@/components/runtime/runtime-status-screen" +import { Titlebar } from "@/components/titlebar/titlebar" +import { Sidebar } from "@/components/sidebar/sidebar" +import { useTabStore } from "@/store/use-tab-store" +import { useNavStore } from "@/store/use-nav-store" +import { useAuthStore } from "@/store/use-auth-store" +import { useDesktopJobsStore } from "@/store/use-desktop-jobs-store" +import { useDesktopRuntimeStore } from "@/store/use-desktop-runtime-store" +import { pageVariants, pageTransition } from "@/lib/animations" +import { windowService } from "@/lib/window-service" +import { getDesktopJobs, getWatchFolders } from "@/services/desktop-service" +import { importGlobalPath } from "@/services/source-service" +import type { DesktopWindowKind, DesktopWindowRoute } from "@/types" +import { Clock, User } from "lucide-react" -export default function App() { - const { isAuthenticated, setAuth, clearAuth } = useAuthStore(); - const [isChecking, setIsChecking] = useState(true); +const LoginPage = lazy(() => import("@/pages/login/login-page").then((module) => ({ default: module.LoginPage }))) +const HomePage = lazy(() => import("@/pages/home/home-page").then((module) => ({ default: module.HomePage }))) +const NotebooksPage = lazy(() => import("@/pages/notebooks/notebooks-page").then((module) => ({ default: module.NotebooksPage }))) +const EditorPage = lazy(() => import("@/pages/editor/editor-page").then((module) => ({ default: module.EditorPage }))) +const KnowledgePage = lazy(() => import("@/pages/knowledge/knowledge-page").then((module) => ({ default: module.KnowledgePage }))) +const ChatPage = lazy(() => import("@/pages/chat/chat-page").then((module) => ({ default: module.ChatPage }))) +const SettingsPage = lazy(() => import("@/pages/settings/settings-page").then((module) => ({ default: module.SettingsPage }))) +const QuickCapturePage = lazy(() => + import("@/pages/quick-capture/quick-capture-page").then((module) => ({ default: module.QuickCapturePage })), +) + +function PlaceholderPage({ + icon: Icon, + title, +}: { + icon: React.ComponentType<{ size?: number; className?: string }> + title: string +}) { + return ( + + +

{title}

+

即将推出

+
+ ) +} + +const TabContent = memo(function TabContent() { + const { tabs, activeTabId } = useTabStore() + const activeTab = tabs.find((t) => t.id === activeTabId) + + if (!activeTab) return null + + return ( + + + 加载中...}> + {activeTab.type === "home" && } + {activeTab.type === "notebooks" && } + {activeTab.type === "editor" && ( + + )} + {activeTab.type === "knowledge" && } + {activeTab.type === "chat" && ( + + )} + {activeTab.type === "settings" && } + + {activeTab.type === "scheduled" && ( + + )} + {activeTab.type === "profile" && ( + + )} + + + ) +}) + +function TrafficLights({ showToggle = false, onToggle }: { showToggle?: boolean; onToggle?: () => void }) { + return createPortal( +
+ + + + {showToggle && onToggle && ( + + + + + + )} +
, + document.body + ) +} + +export function App() { + const token = useAuthStore((s) => s.token) + const sidebarExpanded = useNavStore((s) => s.sidebarExpanded) + const toggleSidebar = useNavStore((s) => s.toggleSidebar) + const setActiveSection = useNavStore((s) => s.setActiveSection) + const openTab = useTabStore((s) => s.openTab) + const applyProgressEvent = useDesktopJobsStore((s) => s.applyProgressEvent) + const setJobs = useDesktopJobsStore((s) => s.setJobs) + const status = useDesktopRuntimeStore((s) => s.status) + const runtimeChecked = useDesktopRuntimeStore((s) => s.runtimeChecked) + const sessionHydrated = useDesktopRuntimeStore((s) => s.sessionHydrated) + const setStatus = useDesktopRuntimeStore((s) => s.setStatus) + const markRuntimeChecked = useDesktopRuntimeStore((s) => s.markRuntimeChecked) + const markSessionHydrated = useDesktopRuntimeStore((s) => s.markSessionHydrated) + const hydratingSessionRef = useRef(false) + const previousRuntimeStateRef = useRef(null) + const currentWindowKind = windowService.label as DesktopWindowKind + const [windowRoute, setWindowRoute] = useState(null) + const [chatWindowSeed, setChatWindowSeed] = useState(0) + + useEffect(() => { + let cancelled = false + let unlisten: (() => void) | undefined + + async function hydrateIfNeeded() { + if (hydratingSessionRef.current || useDesktopRuntimeStore.getState().sessionHydrated) return + hydratingSessionRef.current = true + try { + await hydrateDesktopAuthSession() + } finally { + hydratingSessionRef.current = false + if (!cancelled) { + markSessionHydrated() + } + } + } + + async function bootstrap() { + try { + const initialStatus = await runtimeStatus() + if (!cancelled) { + setStatus(initialStatus) + } + if (initialStatus.state === "ready") { + await hydrateIfNeeded() + } + } catch (error) { + if (!cancelled) { + setStatus({ + state: "degraded", + mode: "source", + health_url: "", + api_base_url: "", + pid: null, + version: null, + last_error: (error as Error)?.message ?? "Failed to bootstrap desktop runtime.", + last_exit_reason: null, + last_healthcheck_at: null, + last_heartbeat_at: null, + log_path: "", + state_dir: "", + sidecar_path: null, + restart_count: 0, + watcher_count: 0, + watchers_paused: false, + last_restart_at: null, + }) + markSessionHydrated() + } + } finally { + if (!cancelled) { + markRuntimeChecked() + } + } + + unlisten = await listenRuntimeState(async (nextStatus) => { + if (cancelled) return + setStatus(nextStatus) + if (nextStatus.state === "ready") { + await hydrateIfNeeded() + } + }) + } + + void bootstrap() + + return () => { + cancelled = true + unlisten?.() + } + }, [markRuntimeChecked, markSessionHydrated, setStatus]) useEffect(() => { - // 验证本地存储的 token 是否仍有效 - if (!isAuthenticated) { - setIsChecking(false); - return; - } - const authService = createAuthService(getHttpClient()); - authService - .getMe() - .then((me) => { - setAuth( - { id: me.id, username: me.username, name: me.name, email: me.email, avatar_url: me.avatar_url }, - localStorage.getItem("lyranote_token") ?? "" - ); + if (!status || status.state === previousRuntimeStateRef.current) { + return + } + const previous = previousRuntimeStateRef.current + previousRuntimeStateRef.current = status.state + if (previous === "ready" && status.state === "degraded") { + void notificationShow({ + kind: "Runtime", + title: "LyraNote 本地服务异常", + body: status.last_error ?? "桌面 sidecar 已退出或不可用。", }) - .catch(() => clearAuth()) - .finally(() => setIsChecking(false)); - }, []); + } + }, [status]) + + useEffect(() => { + let cancelled = false + let unlisten: (() => void) | undefined + + void (async () => { + unlisten = await listenDesktopWindowRoute((payload) => { + if (cancelled) return + setWindowRoute(payload) + if (currentWindowKind === "chat" && payload.initialMessage) { + setChatWindowSeed((value) => value + 1) + } + }) + })() + + return () => { + cancelled = true + unlisten?.() + } + }, [currentWindowKind]) + + useEffect(() => { + if (!token || status?.state !== "ready") { + return + } + + let cancelled = false + void (async () => { + try { + const [jobs, folders] = await Promise.all([ + getDesktopJobs(), + getWatchFolders(), + ]) + if (cancelled) return + setJobs(jobs) + await watchFoldersSync(folders) + } catch (error) { + console.warn("Failed to bootstrap desktop jobs/watch folders", error) + } + })() + + return () => { + cancelled = true + } + }, [setJobs, status?.state, token]) + + useEffect(() => { + if (currentWindowKind !== "main" || !windowRoute) { + return + } - if (isChecking) { + if (windowRoute.section) { + const nextSection = windowRoute.section as Parameters[0] + setActiveSection(nextSection) + if (nextSection === "knowledge") { + openTab({ type: "knowledge", title: "知识库" }) + } + if (nextSection === "notebooks") { + openTab({ type: "notebooks", title: "笔记本" }) + } + if (nextSection === "settings") { + openTab({ type: "settings", title: "设置" }) + } + } + }, [currentWindowKind, openTab, setActiveSection, windowRoute]) + + useEffect(() => { + let cancelled = false + let unlistenJob: (() => void) | undefined + let unlistenImport: (() => void) | undefined + let unlistenDrop: (() => void) | undefined + + async function attach() { + unlistenJob = await listenJobProgress((event) => { + if (cancelled) return + applyProgressEvent(event) + }) + + unlistenImport = await listenImportResults((event) => { + if (cancelled) return + if (event.payload.state === "succeeded") { + void notificationShow({ + kind: "导入完成", + title: "知识库已更新", + body: event.payload.path ?? "桌面导入任务已完成。", + }) + return + } + if (event.payload.state === "failed") { + void notificationShow({ + kind: "导入失败", + title: "知识库导入失败", + body: event.payload.error ?? event.payload.path ?? "桌面导入任务失败。", + }) + } + }) + + unlistenDrop = await listenWindowFileDrop((paths) => { + if (cancelled || status?.state !== "ready" || !token) { + return + } + + const filePaths = paths.filter((path) => !path.endsWith("/")) + const directoryPaths = paths.filter((path) => path.endsWith("/")) + + if (directoryPaths.length > 0) { + void notificationShow({ + kind: "拖拽导入", + title: "目录暂不支持直接拖入", + body: "请在知识库页注册监听目录来持续导入文件夹内容。", + }) + } + + if (filePaths.length === 0) { + return + } + + void Promise.allSettled(filePaths.map((path) => importGlobalPath(path))).then((results) => { + const failed = results.filter((result) => result.status === "rejected") + if (failed.length > 0) { + void notificationShow({ + kind: "拖拽导入", + title: "部分文件导入失败", + body: `共 ${failed.length} 个文件未能加入知识库队列。`, + }) + return + } + void notificationShow({ + kind: "拖拽导入", + title: "文件已加入知识库", + body: `已提交 ${filePaths.length} 个文件到本地导入队列。`, + }) + }) + }) + } + + void attach() + + return () => { + cancelled = true + unlistenJob?.() + unlistenImport?.() + unlistenDrop?.() + } + }, [applyProgressEvent, status?.state, token]) + + async function handleRestartRuntime() { + const nextStatus = await runtimeRestart() + setStatus(nextStatus) + } + + async function handleRevealLogs() { + if (!status?.log_path) return + await fileReveal(status.log_path) + } + + if (!runtimeChecked || !status || status.state === "starting") return ( +
{ if (e.button === 0) void windowService.startDragging() }}> + + +
+ ) + + if (status.state !== "ready") return ( +
{ if (e.button === 0 && !(e.target as HTMLElement).closest("button,input,a,[role=button]")) void windowService.startDragging() }}> + + void handleRestartRuntime()} onRevealLogs={() => void handleRevealLogs()} /> +
+ ) + + if (!sessionHydrated) return ( +
{ if (e.button === 0) void windowService.startDragging() }}> + + +
+ ) + + if (!token) return ( +
{ if (e.button === 0 && !(e.target as HTMLElement).closest("button,input,a,[role=button]")) void windowService.startDragging() }}> + + + + +
+ ) + + function handleDragStart(e: React.MouseEvent) { + if (e.button !== 0) return + const target = e.target as HTMLElement + if (target.closest("button, input, textarea, a, [role=button]")) return + void windowService.startDragging() + } + + if (currentWindowKind === "quick-capture") { return ( -
-
+
+ +
+ + + +
- ); + ) } - if (!isAuthenticated) { - return window.location.reload()} />; + if (currentWindowKind === "chat") { + return ( +
+ +
+ + + +
+
+ ) } - return ; + return ( +
+ + + {/* ── Sidebar column ── */} +
+
+ +
+
+ + {/* ── Right content block ── */} +
+
+ +
+
+ +
+
+
+ ) } + +export default App diff --git a/apps/desktop/src/assets/react.svg b/apps/desktop/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/desktop/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/components/ai-panel/ai-panel.tsx b/apps/desktop/src/components/ai-panel/ai-panel.tsx deleted file mode 100644 index 191ccfa..0000000 --- a/apps/desktop/src/components/ai-panel/ai-panel.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { Bot, ChevronRight, Send, Square, X } from "lucide-react"; -import { useUiStore } from "@/store/use-ui-store"; -import { getHttpClient } from "@/lib/http-client"; -import { - createConversationService, - readSseStream, - type SseChunk, -} from "@lyranote/api-client"; -import { cn } from "@/lib/utils"; - -interface ChatMessage { - id: string; - role: "user" | "assistant"; - content: string; - isStreaming?: boolean; -} - -export function AiPanel() { - const { isAiPanelOpen, toggleAiPanel, selectedNotebookId } = useUiStore(); - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const abortRef = useRef(null); - const messagesEndRef = useRef(null); - const convIdRef = useRef(null); - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }; - - const sendMessage = useCallback(async () => { - if (!input.trim() || isLoading || !selectedNotebookId) return; - - const userMsg: ChatMessage = { - id: Date.now().toString(), - role: "user", - content: input.trim(), - }; - const assistantMsgId = (Date.now() + 1).toString(); - - setMessages((prev) => [ - ...prev, - userMsg, - { id: assistantMsgId, role: "assistant", content: "", isStreaming: true }, - ]); - setInput(""); - setIsLoading(true); - - const abort = new AbortController(); - abortRef.current = abort; - - try { - const conversationService = createConversationService(getHttpClient()); - - if (!convIdRef.current) { - const conv = await conversationService.createConversation( - selectedNotebookId, - userMsg.content.slice(0, 60), - "chat" - ); - convIdRef.current = conv.id; - } - - const response = await conversationService.streamMessage( - convIdRef.current, - { content: userMsg.content }, - abort.signal - ); - - let fullContent = ""; - await readSseStream(response, (chunk: SseChunk) => { - if (chunk.type === "token") { - fullContent += chunk.content; - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, content: fullContent } : m - ) - ); - scrollToBottom(); - } else if (chunk.type === "done") { - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId ? { ...m, isStreaming: false } : m - ) - ); - } - }); - } catch (err) { - if ((err as Error).name !== "AbortError") { - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: "Error: Failed to get response.", isStreaming: false } - : m - ) - ); - } - } finally { - setIsLoading(false); - abortRef.current = null; - } - }, [input, isLoading, selectedNotebookId]); - - const stopStreaming = () => { - abortRef.current?.abort(); - setIsLoading(false); - setMessages((prev) => - prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)) - ); - }; - - if (!isAiPanelOpen) { - return ( - - ); - } - - return ( -
- {/* 头部 */} -
-
- - AI Assistant -
- -
- - {/* 消息列表 */} -
- {messages.length === 0 && ( -
- -

- Ask anything about your notebooks -

-
- )} - {messages.map((msg) => ( - - ))} -
-
- - {/* 输入框 */} -
- {!selectedNotebookId && ( -

- Select a notebook to start chatting -

- )} -
-