Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/agendas/outcome_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified docs/assets/demo_meetflow.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
86 changes: 86 additions & 0 deletions frontend/src/api/meetings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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')
}

62 changes: 58 additions & 4 deletions frontend/src/components/AgendaDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<UserRef[]>([])
let memberRequest: Promise<void> | null = null
const attachmentItems = ref<Attachment[] | null>(null)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -443,14 +484,27 @@ const noteActionTags = computed(() => {
<label>主讲人<n-select v-model:value="draft.presenter_user_id" :options="memberSelectOptions" filterable clearable :virtual-scroll="false" :disabled="!canContribute" :input-props="{ 'aria-label': '主讲人' }" placeholder="未指定" @update:show="onMemberMenu" /></label>
</div>
<div class="agenda-notes">
<span v-if="!canContribute || !hasAgendaNotesAssistant" class="agenda-notes-label">议题记录</span>
<span v-if="!canContribute" class="agenda-notes-label">议题记录</span>
<PluginEditorSlot v-if="canContribute" v-model="draft.notes_markdown" editor-label="议题记录" data-testid="agenda-notes-editor" target-type="agenda_item" :target-id="item.id" slot="agenda-notes-editor" :metadata="{ projectId: meeting.project.id, meetingId: meeting.id, agendaId: item.id }" @notice="error = $event">
<template #header-actions="{ disabled }">
<button
v-if="!meetingLocked"
type="button"
class="ai-inline-extract-btn"
data-testid="ai-extract-notes-btn"
:disabled="saving || disabled || extractingNotes"
@click="smartExtractNotes"
>
✨ {{ extractingNotes ? '正在提炼…' : 'AI 智能提炼' }}
</button>
</template>
<template #editor="{ disabled, registerEditor }">
<MarkdownEditor ref="notesEditor" v-model="draft.notes_markdown" label="议题记录" placeholder="记录讨论上下文、材料和过程…" :disabled="saving || disabled" :register-editor="registerEditor" />
<MarkdownEditor ref="notesEditor" v-model="draft.notes_markdown" label="议题记录" placeholder="记录讨论上下文、材料和过程…" :disabled="saving || disabled || extractingNotes" :register-editor="registerEditor" />
</template>
</PluginEditorSlot>
<MarkdownEditor v-else ref="notesEditor" v-model="draft.notes_markdown" label="议题记录" placeholder="记录讨论上下文、材料和过程…" :disabled="true" />
</div>
<p v-if="extractStatus" class="ai-extract-status" data-testid="ai-extract-status" role="status">{{ extractStatus }}</p>
<p v-if="error" class="notice notice-error">{{ error }}</p>
<div v-if="canContribute" class="agenda-save-row"><span class="muted">保存后会同步识别记录中的 @决策:、@行动:、@开放问题:。</span><button class="button button-quiet" :disabled="saving || !draft.title.trim()" @click="save()">保存议题</button></div>

Expand Down
Loading
Loading