From 183b3e5159fd5bcff88f14ad7c9a59f3d0742cf7 Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Wed, 14 Oct 2026 08:16:48 +0200 Subject: [PATCH] feat(api,ui): add headless API key auth and atomic AI meeting notes ingestion --- backend/app/auth/dependencies.py | 44 +- backend/app/auth/router.py | 54 +++ backend/app/auth/schemas.py | 7 + backend/app/auth/service.py | 28 ++ backend/app/main.py | 10 +- backend/app/meetings/router.py | 140 +++++- backend/tests/api/test_ai_api_and_ingest.py | 119 +++++ frontend/src/api/meetings.ts | 44 ++ frontend/src/components/AiIngestModal.vue | 408 ++++++++++++++++++ frontend/src/components/FocusMode.vue | 19 + .../src/tests/focus-and-integrations.test.ts | 96 +++++ frontend/src/views/MeetingWorkspaceView.vue | 4 + 12 files changed, 969 insertions(+), 4 deletions(-) create mode 100644 backend/tests/api/test_ai_api_and_ingest.py create mode 100644 frontend/src/components/AiIngestModal.vue diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py index 9e4b587..66a48dd 100644 --- a/backend/app/auth/dependencies.py +++ b/backend/app/auth/dependencies.py @@ -9,8 +9,45 @@ def optional_current_user( request: Request, session: Session = Depends(get_session) ) -> User | None: - value = request.cookies.get(request.app.state.auth_service.cookie_name) - parsed = request.app.state.auth_service.read_cookie(value) if value else None + service = request.app.state.auth_service + + api_key = request.headers.get("x-api-key") + if not api_key: + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + bearer_token = auth_header[7:].strip() + if bearer_token.startswith("mf_"): + api_key = bearer_token + elif bearer_token: + parsed_cookie = service.read_cookie(bearer_token) + if not parsed_cookie: + return None + user_id, session_version = parsed_cookie + user = session.get(User, user_id) + if ( + not user + or user.status != UserStatus.ACTIVE + or user.session_version != session_version + ): + return None + return user + + if api_key is not None: + parsed_key = service.parse_api_key(api_key) + if not parsed_key: + return None + user_id, normalized_key = parsed_key + user = session.get(User, user_id) + if ( + not user + or user.status != UserStatus.ACTIVE + or not service.verify_api_key(normalized_key, user) + ): + return None + return user + + value = request.cookies.get(service.cookie_name) + parsed = service.read_cookie(value) if value else None if not parsed: return None @@ -33,6 +70,9 @@ def current_user( return user +get_current_user = current_user + + def admin_user(user: User = Depends(current_user)) -> User: if user.role != UserRole.ADMIN: raise AppError(403, "admin_required", "需要管理员权限") diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index b61899f..632717a 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -9,6 +9,7 @@ from app.auth.models import User, UserRole, UserStatus from app.auth.schemas import ( AdminCreateUserRequest, + ApiKeyResponse, LoginRequest, PasswordRequest, RegisterRequest, @@ -119,6 +120,59 @@ def session_status( return {"user": public_user(user) if user else None} +@router.get("/api-key", response_model=ApiKeyResponse) +def get_api_key( + request: Request, + user: User = Depends(current_user), +) -> ApiKeyResponse: + service = request.app.state.auth_service + key = service.issue_api_key(user) + return ApiKeyResponse( + api_key=key, + token_prefix=f"{key[:12]}...", + user_id=user.id, + username=user.username, + ) + + +@router.post("/api-key/rotate", response_model=ApiKeyResponse) +def rotate_api_key( + request: Request, + response: Response, + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> ApiKeyResponse: + service = request.app.state.auth_service + user.session_version += 1 + session.commit() + session.refresh(user) + if request.cookies.get(service.cookie_name): + response.set_cookie( + service.cookie_name, + service.issue_cookie(user), + max_age=service.cookie_max_age, + httponly=True, + secure=request.app.state.settings.secure_cookies, + samesite="strict", + ) + key = service.issue_api_key(user) + return ApiKeyResponse( + api_key=key, + token_prefix=f"{key[:12]}...", + user_id=user.id, + username=user.username, + ) + + +v1_router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) +v1_router.add_api_route( + "/api-key", get_api_key, methods=["GET"], response_model=ApiKeyResponse +) +v1_router.add_api_route( + "/api-key/rotate", rotate_api_key, methods=["POST"], response_model=ApiKeyResponse +) + + @router.post("/change-password", status_code=204) def change_password( payload: PasswordRequest, diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index 2fafa74..a12dd1c 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -40,3 +40,10 @@ class UserResponse(BaseModel): avatar_color: str role: str status: str + + +class ApiKeyResponse(BaseModel): + api_key: str + token_prefix: str + user_id: str + username: str diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 64bdae9..a85dbc1 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,4 +1,6 @@ from datetime import datetime, timezone +import hashlib +import hmac import time from itsdangerous import ( @@ -70,6 +72,32 @@ def read_cookie(self, value: str) -> tuple[str, int] | None: return None return str(payload["uid"]), int(payload["sv"]) + def issue_api_key(self, user: User) -> str: + msg = f"meetflow-api-key-v1:{user.id}:{user.session_version}".encode("utf-8") + sig = hmac.new( + self.settings.app_secret_key.encode("utf-8"), + msg, + hashlib.sha256, + ).hexdigest()[:32] + return f"mf_{user.id}_{sig}" + + @staticmethod + def parse_api_key(raw_key: str) -> tuple[str, str] | None: + key = raw_key.strip() + if not key.startswith("mf_"): + return None + body = key[3:] + if "_" not in body: + return None + user_id, sig = body.rsplit("_", 1) + if not user_id or not sig: + return None + return user_id, key + + def verify_api_key(self, api_key: str, user: User) -> bool: + expected = self.issue_api_key(user) + return hmac.compare_digest(api_key.strip(), expected) + def transition_user( self, session: Session, diff --git a/backend/app/main.py b/backend/app/main.py index 000baf3..7431077 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from app.agendas.router import router as agendas_router from app.attachments.router import router as attachments_router from app.attachments.storage import AttachmentStorage -from app.auth.router import admin_router, router as auth_router +from app.auth.router import admin_router, router as auth_router, v1_router as auth_v1_router from app.calendar.router import router as calendar_router from app.auth.service import AuthService from app.config import Settings @@ -97,6 +97,7 @@ async def lifespan(_app: FastAPI): app.state.series_scheduler = series_scheduler install_error_handlers(app) app.include_router(auth_router) + app.include_router(auth_v1_router) app.include_router(admin_router) app.include_router(meetings_router) app.include_router(agendas_router) @@ -116,6 +117,13 @@ async def lifespan(_app: FastAPI): @app.middleware("http") async def reject_cross_origin_writes(request: Request, call_next): + path = request.scope.get("path", "") + if ( + path.startswith("/api/v1/") + and not path.startswith("/api/v1/auth/api-key") + and not path.endswith("/ingest") + ): + request.scope["path"] = "/api/" + path[len("/api/v1/") :] origin = request.headers.get("origin", "").rstrip("/") if ( request.method in {"POST", "PUT", "PATCH", "DELETE"} diff --git a/backend/app/meetings/router.py b/backend/app/meetings/router.py index 7006d72..5a81aec 100644 --- a/backend/app/meetings/router.py +++ b/backend/app/meetings/router.py @@ -2,14 +2,21 @@ from fastapi import APIRouter, Depends, Query from fastapi.responses import JSONResponse -from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator from sqlalchemy.orm import Session +from app.agendas.lifecycle import actual_duration_seconds +from app.agendas.models import AgendaItem +from app.agendas.outcome_tags import parse_outcome_tags +from app.agendas.service import AgendaService from app.auth.dependencies import current_user from app.auth.models import User from app.collaboration.activity import ActivityRecorder from app.database import get_session +from app.domain.enums import AgendaStatus, AgendaType +from app.errors import AppError from app.http import utc_response +from app.meetings.policies import LifecyclePolicy from app.meetings.schemas import ( AmendmentWrite, LifecycleCommand, @@ -347,3 +354,134 @@ async def notify_meeting_webhook( session.commit() return utc_response({"status": "ok", "response": res, "webhook_type": payload.webhook_type}) + +class MeetingIngestRequest(BaseModel): + agenda_title: str | None = Field(default=None, max_length=300) + agenda_item_id: str | int | None = None + notes_markdown: str = Field(min_length=1, max_length=200_000) + auto_complete_agenda: bool = False + + @field_validator("notes_markdown") + @classmethod + def require_notes(cls, value: str) -> str: + if not value.strip(): + raise ValueError("notes_markdown must not be blank") + return value + + +@router.post("/api/meetings/{meeting_id}/ingest") +@router.post("/api/v1/meetings/{meeting_id}/ingest") +def ingest_meeting_notes( + meeting_id: str, + payload: MeetingIngestRequest, + user: User = Depends(current_user), + session: Session = Depends(get_session), +) -> dict[str, Any]: + meeting = WorkspaceAccess(session).require_meeting_contribute(meeting_id, user) + LifecyclePolicy.require_mutable_status(meeting.status) + agenda_service = AgendaService(session) + tags = parse_outcome_tags(payload.notes_markdown) + + target_id = ( + str(payload.agenda_item_id).strip() + if payload.agenda_item_id is not None + else "" + ) + if target_id: + item = agenda_service.get(target_id) + if item.meeting_id != meeting.id: + raise AppError(404, "agenda_item_not_found", "议题不存在") + if payload.agenda_title and payload.agenda_title.strip(): + item.title = payload.agenda_title.strip() + item.notes_markdown = payload.notes_markdown + item.updated_by = user.id + item.version += 1 + meeting.updated_by = user.id + meeting.version += 1 + agenda_service._reconcile_derived_outcomes(item, tags, user) + else: + raw_title = (payload.agenda_title or "").strip() + title = raw_title or "AI 纪要导入" + items = agenda_service.list(meeting.id) + existing = next( + (row for row in items if raw_title and row.title.strip() == raw_title), + None, + ) + if existing is not None: + item = existing + item.notes_markdown = payload.notes_markdown + item.updated_by = user.id + item.version += 1 + meeting.updated_by = user.id + meeting.version += 1 + agenda_service._reconcile_derived_outcomes(item, tags, user) + else: + item = AgendaItem( + meeting_id=meeting.id, + title=title, + agenda_type=AgendaType.discussion, + estimated_minutes=15, + notes_markdown=payload.notes_markdown, + status=AgendaStatus.planned, + position=len(items), + version=1, + created_by=user.id, + updated_by=user.id, + ) + session.add(item) + meeting.version += 1 + meeting.updated_by = user.id + session.flush() + agenda_service._reconcile_derived_outcomes(item, tags, user) + + if payload.auto_complete_agenda and item.status in { + AgendaStatus.planned, + AgendaStatus.in_progress, + }: + now = utcnow() + if item.started_at is None: + item.started_at = now + item.status = AgendaStatus.completed + item.completed_at = now + item.actual_duration_seconds = actual_duration_seconds(item, now) + + decisions_count = sum(1 for t in tags if t.kind == "decision") + actions_count = sum(1 for t in tags if t.kind == "action") + questions_count = sum(1 for t in tags if t.kind == "question") + + ActivityRecorder(session).record( + project_id=meeting.project_id, + meeting_id=meeting.id, + actor_user_id=user.id, + event_type="meeting.notes_ingested", + subject_type="agenda_item", + subject_id=item.id, + payload={ + "title": item.title, + "decisions": decisions_count, + "actions": actions_count, + "questions": questions_count, + }, + ) + session.commit() + + service = MeetingService(session) + detail = service.meeting_detail(meeting_id, user) + capabilities = WorkspaceAccess(session).meeting_capabilities(meeting, user) + detail["capabilities"] = _capability_payload(capabilities) + return utc_response( + { + **detail, + "meeting": detail, + "ingested_agenda_item_id": item.id, + "decisions": decisions_count, + "actions": actions_count, + "questions": questions_count, + "extracted": { + "decisions": decisions_count, + "actions": actions_count, + "questions": questions_count, + }, + } + ) + diff --git a/backend/tests/api/test_ai_api_and_ingest.py b/backend/tests/api/test_ai_api_and_ingest.py new file mode 100644 index 0000000..684aec1 --- /dev/null +++ b/backend/tests/api/test_ai_api_and_ingest.py @@ -0,0 +1,119 @@ +def test_api_key_lifecycle_and_headless_auth(authenticated_client, client, meeting_id): + # 1. Fetch personal API key via browser session + res = authenticated_client.get("/api/v1/auth/api-key") + assert res.status_code == 200 + data = res.json() + api_key = data["api_key"] + assert api_key.startswith(f"mf_{data['user_id']}_") + assert data["username"] == "admin" + + # 2. Authenticate headless client (no cookies) using X-API-Key and Authorization: Bearer + me_res = client.get( + "/api/auth/me", + cookies={}, + headers={"X-API-Key": api_key}, + ) + assert me_res.status_code == 200 + assert me_res.json()["username"] == "admin" + + bearer_res = client.get( + "/api/meetings/" + meeting_id, + cookies={}, + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert bearer_res.status_code == 200 + assert bearer_res.json()["id"] == meeting_id + + # 3. Rotate API key: old key must be invalidated immediately, new key works, browser cookie remains valid + rot_res = authenticated_client.post("/api/v1/auth/api-key/rotate") + assert rot_res.status_code == 200 + new_key = rot_res.json()["api_key"] + assert new_key != api_key + assert new_key.startswith(f"mf_{data['user_id']}_") + + old_check = client.get( + "/api/auth/me", + cookies={}, + headers={"X-API-Key": api_key}, + ) + assert old_check.status_code == 401 + + new_check = client.get( + "/api/auth/me", + cookies={}, + headers={"X-API-Key": new_key}, + ) + assert new_check.status_code == 200 + + # Browser session cookie was refreshed during rotate + assert authenticated_client.get("/api/auth/me").status_code == 200 + + +def test_atomic_meeting_notes_ingestion_via_api_key(authenticated_client, client, meeting_id): + api_key = authenticated_client.get("/api/v1/auth/api-key").json()["api_key"] + + # 1. Ingest notes without agenda_item_id -> creates new agenda item and extracts outcomes + ingest_res = client.post( + f"/api/v1/meetings/{meeting_id}/ingest", + cookies={}, + headers={"X-API-Key": api_key}, + json={ + "agenda_title": "AI 架构评审纪要", + "notes_markdown": ( + "### 核心结论\n" + "@决策: 统一采用单源 Canonical API 架构\n" + "@决策: 支持 X-API-Key 无头鉴权\n" + "@行动:[in_progress][@admin][2026-10-25] 完成自动化回归测试\n" + "@开放问题: 是否开启跨项目 Webhook 汇总?\n" + ), + "auto_complete_agenda": False, + }, + ) + assert ingest_res.status_code == 200 + body = ingest_res.json() + assert body["decisions"] == 2 + assert body["actions"] == 1 + assert body["questions"] == 1 + assert body["extracted"] == {"decisions": 2, "actions": 1, "questions": 1} + + agenda_id = body["ingested_agenda_item_id"] + agenda_items = [a for a in body["agenda_items"] if a["id"] == agenda_id] + assert len(agenda_items) == 1 + agenda = agenda_items[0] + assert agenda["title"] == "AI 架构评审纪要" + assert agenda["status"] == "planned" + assert len(agenda["decisions"]) == 2 + assert len(agenda["actions"]) == 1 + assert agenda["actions"][0]["content"] == "完成自动化回归测试" + assert agenda["actions"][0]["status"] == "in_progress" + assert len(agenda["open_questions"]) == 1 + assert agenda["open_questions"][0]["question_markdown"] == "是否开启跨项目 Webhook 汇总?" + + # 2. Update the existing agenda item by agenda_item_id and auto_complete_agenda=True + update_res = client.post( + f"/api/v1/meetings/{meeting_id}/ingest", + cookies={}, + headers={"X-API-Key": api_key}, + json={ + "agenda_item_id": agenda_id, + "notes_markdown": ( + "@决策: 统一采用单源 Canonical API 架构\n" + "@行动:[done][@admin][2026-10-25] 完成自动化回归测试\n" + ), + "auto_complete_agenda": True, + }, + ) + assert update_res.status_code == 200 + updated_body = update_res.json() + assert updated_body["decisions"] == 1 + assert updated_body["actions"] == 1 + assert updated_body["questions"] == 0 + + updated_agenda = next( + a for a in updated_body["agenda_items"] if a["id"] == agenda_id + ) + assert updated_agenda["status"] == "completed" + assert len(updated_agenda["decisions"]) == 1 + assert len(updated_agenda["actions"]) == 1 + assert updated_agenda["actions"][0]["status"] == "done" + assert len(updated_agenda["open_questions"]) == 0 diff --git a/frontend/src/api/meetings.ts b/frontend/src/api/meetings.ts index 158c2c1..bf1baf4 100644 --- a/frontend/src/api/meetings.ts +++ b/frontend/src/api/meetings.ts @@ -30,3 +30,47 @@ export function runMeetingLifecycle(id: string, action: LifecycleAction, expecte export function downloadMeetingExport(id: string, exporterId: string) { return apiDownload(`/api/meetings/${id}/plugin-exports/${encodeURIComponent(exporterId)}`, { method: 'POST' }) } + +export type MeetingIngestPayload = { + agenda_title?: string + agenda_item_id?: string | number | null + notes_markdown: string + auto_complete_agenda?: boolean +} + +export type MeetingIngestResponse = Meeting & { + meeting?: Meeting + ingested_agenda_item_id: string + decisions: number + actions: number + questions: number + extracted: { + decisions: number + actions: number + questions: number + } +} + +export type ApiKeyInfo = { + api_key: string + token_prefix: string + user_id: string + username: string +} + +export function ingestMeetingNotes(id: string, payload: MeetingIngestPayload) { + return api(`/api/v1/meetings/${id}/ingest`, { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function fetchApiKey() { + return api('/api/v1/auth/api-key') +} + +export function rotateApiKey() { + return api('/api/v1/auth/api-key/rotate', { + method: 'POST', + }) +} diff --git a/frontend/src/components/AiIngestModal.vue b/frontend/src/components/AiIngestModal.vue new file mode 100644 index 0000000..33dc7de --- /dev/null +++ b/frontend/src/components/AiIngestModal.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/frontend/src/components/FocusMode.vue b/frontend/src/components/FocusMode.vue index f57ce84..56c9b24 100644 --- a/frontend/src/components/FocusMode.vue +++ b/frontend/src/components/FocusMode.vue @@ -9,6 +9,7 @@ import { parseUtcTimestamp } from '../utils/time' import MarkdownEditor from './MarkdownEditor.vue' import type { MarkdownEditorHandle } from './MarkdownEditor.vue' import OutcomeComposer from './OutcomeComposer.vue' +import AiIngestModal from './AiIngestModal.vue' import { Check, ChevronLeft, ChevronRight } from '@lucide/vue' const props = defineProps<{ @@ -29,6 +30,7 @@ const saving = ref(false) const advancing = ref(false) const composer = ref<'decision' | 'action' | 'question' | null>(null) const hoveredOutcomeId = ref(null) +const aiIngestOpen = ref(false) // Current selected agenda item in Focus Mode const selectedAgendaId = ref('') @@ -330,6 +332,15 @@ const noteActionTags = computed(() => { {{ saving ? '保存中…' : '保存笔记' }} + + @@ -493,6 +504,14 @@ const noteActionTags = computed(() => { + + diff --git a/frontend/src/tests/focus-and-integrations.test.ts b/frontend/src/tests/focus-and-integrations.test.ts index 7999280..806ebaf 100644 --- a/frontend/src/tests/focus-and-integrations.test.ts +++ b/frontend/src/tests/focus-and-integrations.test.ts @@ -31,6 +31,7 @@ vi.mock('../components/MarkdownEditor.vue', () => ({ })) import AgendaDetail from '../components/AgendaDetail.vue' +import AiIngestModal from '../components/AiIngestModal.vue' import CalendarSubscriptionModal from '../components/CalendarSubscriptionModal.vue' import type { Meeting } from '../domain/meetings' import FocusMode from '../components/FocusMode.vue' @@ -301,4 +302,99 @@ describe('P0 Features: Bidirectional Actions, Focus Mode, Calendar & Webhooks', expect(screen.getByLabelText('议题记录')).toHaveValue('正在输入的未保存笔记内容') }) + + it('ingests AI markdown notes and displays/rotates personal API key with curl example', async () => { + const meeting = meetingFixture() + apiMock.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/api/v1/auth/api-key') { + return Promise.resolve({ + api_key: 'mf_u1_abcdef123456', + token_prefix: 'mf_u1_abcdef...', + user_id: 'u1', + username: 'lin', + }) + } + if (url === '/api/v1/auth/api-key/rotate' && init?.method === 'POST') { + return Promise.resolve({ + api_key: 'mf_u1_rotated999999', + token_prefix: 'mf_u1_rotate...', + user_id: 'u1', + username: 'lin', + }) + } + if (url === '/api/v1/meetings/m1/ingest' && init?.method === 'POST') { + return Promise.resolve({ + ...meeting, + meeting, + ingested_agenda_item_id: 'a-new', + decisions: 1, + actions: 1, + questions: 1, + extracted: { decisions: 1, actions: 1, questions: 1 }, + }) + } + return Promise.resolve(meeting) + }) + + const onReload = vi.fn() + const onIngested = vi.fn() + const onUpdateShow = vi.fn() + renderWithProviders(AiIngestModal, { + props: { + show: true, + meeting: meeting as any, + canContribute: true, + onReload, + onIngested, + 'onUpdate:show': onUpdateShow, + }, + }) + + // Switch to Tab 2 to verify API key & curl example + const apiTab = screen.getByRole('tab', { name: 'AI API 密钥与调用示例' }) + await fireEvent.click(apiTab) + + await waitFor(() => { + expect(screen.getByLabelText('AI API 密钥')).toHaveValue('mf_u1_abcdef123456') + }) + expect(screen.getByTestId('ai-curl-example').textContent).toContain('/api/v1/meetings/m1/ingest') + expect(screen.getByTestId('ai-curl-example').textContent).toContain('X-API-Key: mf_u1_abcdef123456') + + // Rotate API key + const rotateBtn = screen.getByRole('button', { name: '轮换密钥' }) + await fireEvent.click(rotateBtn) + await waitFor(() => { + expect(screen.getByLabelText('AI API 密钥')).toHaveValue('mf_u1_rotated999999') + }) + + // Switch back to Tab 1 and submit markdown ingestion + const ingestTab = screen.getByRole('tab', { name: '一键导入纪要' }) + await fireEvent.click(ingestTab) + + const mdInput = screen.getByLabelText('AI 纪要 Markdown') + await fireEvent.update( + mdInput, + '@决策: 采用 Canonical API\n@行动:[todo] 联调验证\n@开放问题: 存量数据迁移?', + ) + + const submitBtn = screen.getByRole('button', { name: '立即导入并提取' }) + await fireEvent.click(submitBtn) + + await waitFor(() => { + expect(apiMock).toHaveBeenCalledWith( + '/api/v1/meetings/m1/ingest', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + agenda_title: 'AI 纪要导入', + notes_markdown: '@决策: 采用 Canonical API\n@行动:[todo] 联调验证\n@开放问题: 存量数据迁移?', + auto_complete_agenda: false, + }), + }), + ) + }) + expect(onIngested).toHaveBeenCalled() + expect(onReload).toHaveBeenCalled() + expect(onUpdateShow).toHaveBeenCalledWith(false) + }) }) diff --git a/frontend/src/views/MeetingWorkspaceView.vue b/frontend/src/views/MeetingWorkspaceView.vue index c88a115..b4bc726 100644 --- a/frontend/src/views/MeetingWorkspaceView.vue +++ b/frontend/src/views/MeetingWorkspaceView.vue @@ -22,6 +22,7 @@ import PluginSlot from '../components/PluginSlot.vue' import CalendarSubscriptionModal from '../components/CalendarSubscriptionModal.vue' import FocusMode from '../components/FocusMode.vue' import WebhookNotifyModal from '../components/WebhookNotifyModal.vue' +import AiIngestModal from '../components/AiIngestModal.vue' import type { Attachment, Meeting, MeetingParticipantWrite } from '../domain/meetings' import type { Project } from '../domain/projects' import { useMeetingWorkspace } from '../composables/useMeetingWorkspace' @@ -39,6 +40,7 @@ const materialsOpen = ref(false) const focusModeOpen = ref(false) const calendarOpen = ref(false) const webhookOpen = ref(false) +const aiIngestOpen = ref(false) const materialItems = ref([]) const now = ref(Date.now()) const minutesSaved = ref(false) @@ -377,6 +379,7 @@ onBeforeUnmount(() => {

{{ error || '会议不存在' }}