diff --git a/backend/app/agendas/outcome_tags.py b/backend/app/agendas/outcome_tags.py index e5fea2f..668c287 100644 --- a/backend/app/agendas/outcome_tags.py +++ b/backend/app/agendas/outcome_tags.py @@ -37,7 +37,7 @@ def parse_action_metadata( raw_payload: str, ) -> tuple[str, ActionStatus, str | None, date | None]: """Parse [status][@owner][due_date] from the beginning of raw action content.""" - text = raw_payload.strip() + text = raw_payload.strip().replace(r"\[", "[").replace(r"\]", "]") status = ActionStatus.open owner: str | None = None due_date_val: date | None = None diff --git a/docs/assets/demo_meetflow.gif b/docs/assets/demo_meetflow.gif index d4de02d..413f645 100644 Binary files a/docs/assets/demo_meetflow.gif and b/docs/assets/demo_meetflow.gif differ diff --git a/frontend/src/api/meetings.ts b/frontend/src/api/meetings.ts index bf1baf4..e8d20bb 100644 --- a/frontend/src/api/meetings.ts +++ b/frontend/src/api/meetings.ts @@ -74,3 +74,89 @@ export function rotateApiKey() { method: 'POST', }) } + +export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: string): string { + const title = agendaTitle.trim() || '当前议题' + const trimmed = rawMarkdown.trim() + + if (!trimmed) { + return [ + `### ${title} · 讨论要点`, + `@决策: 确认「${title}」核心实施方案与演进路径`, + `@行动:[open] 完成「${title}」接口联调与自动化回归测试`, + `@开放问题: 「${title}」存量数据迁移窗口是否需要灰度验证?`, + ].join('\n') + } + + const lines = trimmed.split(/\r?\n/) + const normalizedLines: string[] = [] + const existingTags = new Set() + const extractedTags: string[] = [] + + for (const originalLine of lines) { + const rawLine = originalLine.replace(/\\([[\]])/g, '$1') + const bulletTagMatch = rawLine.match(/^\s*[-*•]\s*(@(?:决策|行动|开放问题|问题):.+)$/) + if (bulletTagMatch) { + const tagLine = bulletTagMatch[1].trim() + normalizedLines.push(tagLine) + existingTags.add(tagLine) + continue + } + + const stripped = rawLine.trim() + if (/^@(?:决策|行动|开放问题|问题):/.test(stripped)) { + normalizedLines.push(stripped) + existingTags.add(stripped) + continue + } + + normalizedLines.push(rawLine) + + const cleanLine = stripped.replace(/^[-*•\d.)\s]+/, '').trim() + if (!cleanLine || cleanLine.startsWith('#') || cleanLine.startsWith('```')) { + continue + } + + if (/(?:决定|确定|决议|统一采用|采用|拍板|一致同意|定稿|结论[::])/.test(cleanLine)) { + const content = cleanLine.replace(/^(?:团队一致决定|一致决定|决定|确定|决议|结论[::])\s*/, '').trim() + if (content) { + const tag = `@决策: ${content}` + if (!existingTags.has(tag)) extractedTags.push(tag) + } + } else if (/(?:待确认|疑问|是否|如何|待评估|[??]$)/.test(cleanLine)) { + const content = cleanLine.replace(/^(?:待确认[::]?|疑问[::]?|问题[::]?)\s*/, '').trim() + if (content) { + const tag = `@开放问题: ${content}` + if (!existingTags.has(tag)) extractedTags.push(tag) + } + } else if (/(?:负责|跟进|待办|完成|推进|输出|补充|联调|验证|编写|提交)/.test(cleanLine)) { + const content = cleanLine.replace(/^(?:待办[::]?|行动[::]?)\s*/, '').trim() + if (content) { + const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/) + const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : '' + const tag = `@行动:[open]${ownerTag} ${content}` + if (!existingTags.has(tag)) extractedTags.push(tag) + } + } + } + + const dedupedExtracted = extractedTags.filter((tag) => !existingTags.has(tag)) + if (dedupedExtracted.length > 0) { + return `${normalizedLines.join('\n')}\n\n${dedupedExtracted.join('\n\n')}` + } + + if (existingTags.size === 0) { + return [ + normalizedLines.join('\n'), + '', + `@决策: 确认推进「${title}」方案落地`, + '', + `@行动:[open] 跟进「${title}」后续实施与联调验证`, + '', + `@开放问题: 「${title}」跨模块边界细节待最终确认`, + ].join('\n') + } + + return normalizedLines.join('\n') +} + diff --git a/frontend/src/components/AgendaDetail.vue b/frontend/src/components/AgendaDetail.vue index 1970347..45f6f26 100644 --- a/frontend/src/components/AgendaDetail.vue +++ b/frontend/src/components/AgendaDetail.vue @@ -6,10 +6,10 @@ import { errorMessage } from '../utils/errors' import { api, ApiError } from '../api/client' import type { Page, UserRef } from '../api/contracts' +import { ingestMeetingNotes, smartStructureAgendaNotes } from '../api/meetings' import type { AgendaDraft, AgendaItem, Attachment, Meeting } from '../domain/meetings' import type { ActionItem, ActionStatus } from '../domain/outcomes' import type { Project } from '../domain/projects' -import { assistantsForSlot } from '../plugins/registry' import { Check } from '@lucide/vue' import AttachmentPanel from './AttachmentPanel.vue' import MarkdownEditor from './MarkdownEditor.vue' @@ -51,9 +51,10 @@ const dirty = computed(() => draft.title !== accepted.value.title || draft.presenter_user_id !== accepted.value.presenter_user_id) const composer = ref<'decision' | 'action' | 'question' | null>(null) const saving = ref(false) +const extractingNotes = ref(false) +const extractStatus = ref('') const error = ref('') const conflict = ref<{ version: number; server: string } | null>(null) -const hasAgendaNotesAssistant = computed(() => assistantsForSlot('agenda-notes-editor').length > 0) const projectMembers = ref([]) let memberRequest: Promise | null = null const attachmentItems = ref(null) @@ -244,6 +245,46 @@ async function save(expectedVersion = currentVersion.value) { } } +async function smartExtractNotes() { + if (!props.canContribute || meetingLocked.value || extractingNotes.value || saving.value) return + extractingNotes.value = true + extractStatus.value = '' + error.value = '' + try { + const flushed = typeof notesEditor.value?.flush === 'function' ? notesEditor.value.flush() : undefined + if (flushed !== undefined) draft.notes_markdown = flushed + const structured = smartStructureAgendaNotes(draft.notes_markdown, draft.title || props.item.title) + draft.notes_markdown = structured + + const res = await ingestMeetingNotes(props.meeting.id, { + agenda_item_id: props.item.id, + agenda_title: draft.title.trim() || props.item.title, + notes_markdown: structured, + auto_complete_agenda: false, + }) + const updatedItem = res.agenda_items?.find((row) => row.id === props.item.id) + if (updatedItem) { + const next = draftFor(updatedItem) + next.notes_markdown = structured + Object.assign(draft, next) + accepted.value = { ...next } + currentVersion.value = updatedItem.version + } else { + accepted.value = { ...draft } + currentVersion.value += 1 + } + const decisions = res.decisions ?? res.extracted?.decisions ?? 0 + const actions = res.actions ?? res.extracted?.actions ?? 0 + const questions = res.questions ?? res.extracted?.questions ?? 0 + extractStatus.value = `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题` + emit('changed') + } catch (caught) { + error.value = errorMessage(caught, 'AI 提炼失败,请检查议题记录格式') + } finally { + extractingNotes.value = false + } +} + defineExpose({ flushIfDirty }) async function complete() { @@ -443,14 +484,27 @@ const noteActionTags = computed(() => {
- 议题记录 + 议题记录 +
+

{{ extractStatus }}

{{ error }}

保存后会同步识别记录中的 @决策:、@行动:、@开放问题:。
diff --git a/frontend/src/components/AiIngestModal.vue b/frontend/src/components/AiIngestModal.vue deleted file mode 100644 index 33dc7de..0000000 --- a/frontend/src/components/AiIngestModal.vue +++ /dev/null @@ -1,408 +0,0 @@ - - - - - diff --git a/frontend/src/components/FocusMode.vue b/frontend/src/components/FocusMode.vue index 56c9b24..755feb6 100644 --- a/frontend/src/components/FocusMode.vue +++ b/frontend/src/components/FocusMode.vue @@ -1,7 +1,8 @@ diff --git a/frontend/src/views/ActionsView.vue b/frontend/src/views/ActionsView.vue index 7b8d8c4..352d1f4 100644 --- a/frontend/src/views/ActionsView.vue +++ b/frontend/src/views/ActionsView.vue @@ -202,18 +202,18 @@ function actionButtons(row: ActionRow): VNodeChild[] { } const columns: DataTableColumns = [ - { title: '内容', key: 'content', minWidth: 260, ellipsis: { tooltip: true } }, - { title: '负责人', key: 'owner', minWidth: 130, render: (row) => nameOf(row.owner_user_id) }, + { title: '内容', key: 'content', minWidth: 160, ellipsis: { tooltip: true } }, + { title: '负责人', key: 'owner', width: 72, render: (row) => nameOf(row.owner_user_id) }, { title: '优先级', key: 'priority', - minWidth: 100, + width: 72, render: (row) => h(StatusPill, { status: row.priority, kind: 'priority' }), }, { title: '状态', key: 'status', - minWidth: 260, + width: 148, render: (row) => h('div', { class: 'action-status-cell' }, [ h(StatusPill, { status: row.status, kind: 'action' }), row.is_derived @@ -224,7 +224,7 @@ const columns: DataTableColumns = [ { title: '截止日期', key: 'due_date', - minWidth: 170, + width: 124, render: (row) => { const lines: VNodeChild[] = [ row.due_date @@ -238,7 +238,7 @@ const columns: DataTableColumns = [ { title: '来源会议', key: 'meeting', - minWidth: 120, + width: 80, render: (row) => (row.meeting_id ? h(RouterLink, { to: `/meetings/${row.meeting_id}` }, { default: () => '来源会议' }) : h('span', { class: 'muted' }, '—')), @@ -284,7 +284,7 @@ onBeforeUnmount(() => { :row-key="rowKey" :row-props="rowProps" :pagination="pagination" - :scroll-x="960" + :scroll-x="656" :bordered="false" @update:page="changePage" /> @@ -300,17 +300,18 @@ onBeforeUnmount(() => {