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
23 changes: 20 additions & 3 deletions backend/app/agendas/outcome_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ class TaggedOutcome:

_TAGS: tuple[tuple[OutcomeKind, str], ...] = (
("decision", "@决策:"),
("decision", "@Decision:"),
("decision", "@decision:"),
("action", "@行动:"),
("action", "@Action:"),
("action", "@action:"),
("question", "@开放问题:"),
("question", "@问题:"),
("question", "@Question:"),
("question", "@question:"),
("question", "@OpenQuestion:"),
)

_ACTION_PREFIXES: tuple[str, ...] = ("@行动:", "@Action:", "@action:")


def parse_action_metadata(
raw_payload: str,
Expand Down Expand Up @@ -79,6 +88,7 @@ def format_action_tag(
status: ActionStatus | str = ActionStatus.open,
owner: str | None = None,
due_date: date | str | None = None,
tag_prefix: str = "@行动:",
) -> str:
"""Format an ActionItem into the tag syntax @行动:[status][@owner][due_date] content."""
status_str = status.value if hasattr(status, "value") else str(status)
Expand All @@ -100,7 +110,7 @@ def format_action_tag(
due_str = due_date.isoformat() if hasattr(due_date, "isoformat") else str(due_date).strip()
if due_str:
parts.append(f"[{due_str}]")
return f"@行动:{''.join(parts)} {content.strip()}"
return f"{tag_prefix}{''.join(parts)} {content.strip()}"


def rewrite_action_in_notes(
Expand All @@ -124,12 +134,19 @@ def rewrite_action_in_notes(
continue
if in_fence:
continue
if line.startswith("@行动:"):
matched_prefix = next(
(prefix for prefix in _ACTION_PREFIXES if line.startswith(prefix)),
None,
)
if matched_prefix is not None:
if action_count == target_index:
leading_ws_len = len(raw_line) - len(raw_line.lstrip())
leading = raw_line[:leading_ws_len]
ending = "\n" if raw_line.endswith("\n") else ""
lines[idx] = f"{leading}{new_tag_line}{ending}"
tag_line = new_tag_line
if matched_prefix != "@行动:" and tag_line.startswith("@行动:"):
tag_line = f"{matched_prefix}{tag_line[len('@行动:'):]}"
lines[idx] = f"{leading}{tag_line}{ending}"
return "".join(lines)
action_count += 1
return notes_markdown
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/domain/test_bidirectional_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,38 @@ def test_reconcile_outcomes_preserves_final_decision_clears_metadata_and_handles
)
assert session.get(Decision, final_dec.id) is not None
assert session.get(Decision, final_dec.id).status.value == "final"


def test_parse_and_rewrite_english_outcome_tags():
md = """# Meeting Notes
@Decision: Adopt canonical REST API contract
@Action:[todo][@alice][2026-10-18] Ship bilingual UI toggle
@Question: Should we preserve legacy webhook aliases?
"""
tags = parse_outcome_tags(md)
assert len(tags) == 3
assert tags[0].kind == "decision"
assert tags[0].source_tag_key == "decision:0"
assert tags[0].content == "Adopt canonical REST API contract"

assert tags[1].kind == "action"
assert tags[1].source_tag_key == "action:0"
assert tags[1].status == ActionStatus.open
assert tags[1].owner_name == "alice"
assert tags[1].due_date == date(2026, 10, 18)
assert tags[1].content == "Ship bilingual UI toggle"

assert tags[2].kind == "question"
assert tags[2].source_tag_key == "question:0"
assert tags[2].content == "Should we preserve legacy webhook aliases?"

# Rewriting an English @Action: tag preserves the @Action: prefix
new_tag = format_action_tag(
content="Ship bilingual UI toggle",
status=ActionStatus.done,
owner="alice",
due_date=date(2026, 10, 18),
)
rewritten = rewrite_action_in_notes(md, "action:0", new_tag)
assert "@Action:[done][@alice][2026-10-18] Ship bilingual UI toggle" in rewritten

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.
38 changes: 27 additions & 11 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,26 @@ import {
NBadge, NConfigProvider, NDialogProvider, NDropdown, NIcon, NLayout, NLayoutContent,
NMessageProvider, dateZhCN, zhCN,
} from 'naive-ui'
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { RouterView, useRouter } from 'vue-router'

import { api } from './api/client'
import { clearSession, session, type SessionUser } from './auth/session'
import AppAvatar from './components/AppAvatar.vue'
import AppSidebar from './components/AppSidebar.vue'
import { resetUnread, startUnreadPolling, unreadCount } from './composables/useInboxUnread'
import { resetUserNames } from './composables/useUserNameMap'
import { useI18n } from './i18n'
import { loadPluginFrontendModules } from './plugins/runtime'
import { naiveThemeOverrides } from './theme/naive'

const router = useRouter()
const { locale, toggleLocale, tr } = useI18n()

const accountOptions = [
{ key: 'account', label: '账号设置' },
{ key: 'logout', label: '退出登录' },
]
const accountOptions = computed(() => [
{ key: 'account', label: tr('账号设置', 'Account Settings') },
{ key: 'logout', label: tr('退出登录', 'Sign Out') },
])

function onLoggedIn(user: SessionUser) {
session.user = user
Expand Down Expand Up @@ -78,7 +80,11 @@ onBeforeUnmount(() => {
</script>

<template>
<NConfigProvider :theme-overrides="naiveThemeOverrides" :locale="zhCN" :date-locale="dateZhCN">
<NConfigProvider
:theme-overrides="naiveThemeOverrides"
:locale="locale === 'zh' ? zhCN : null"
:date-locale="locale === 'zh' ? dateZhCN : null"
>
<NMessageProvider>
<NDialogProvider>
<div class="app-shell">
Expand All @@ -87,15 +93,24 @@ onBeforeUnmount(() => {
<AppSidebar />
<NLayoutContent class="workspace-main">
<header class="workspace-topbar">
<span class="workspace-label">共享工作区</span>
<div class="topbar-actions">
<span class="workspace-label">{{ tr('共享工作区', 'Shared Workspace') }}</span>
<div class="topbar-actions topbar-right">
<button
type="button"
class="lang-toggle-btn"
data-testid="lang-toggle-btn"
:aria-label="locale === 'zh' ? 'Switch to English' : '切换到中文'"
@click="toggleLocale"
>
{{ locale === 'zh' ? '🌐 EN' : '🌐 中文' }}
</button>
<NBadge :value="unreadCount" :show="unreadCount > 0">
<button type="button" class="icon-button" aria-label="收件箱" @click="router.push('/inbox')">
<button type="button" class="icon-button" :aria-label="tr('收件箱', 'Inbox')" @click="router.push('/inbox')">
<NIcon :size="18"><Bell :size="18" aria-hidden="true" /></NIcon>
</button>
</NBadge>
<NDropdown :options="accountOptions" trigger="click" @select="onAccountSelect">
<button type="button" class="account-menu-trigger" aria-label="账户菜单">
<button type="button" class="account-menu-trigger" :aria-label="tr('账户菜单', 'Account menu')">
<AppAvatar :name="session.user.display_name" :color="session.user.avatar_color" :size="30" />
<span>{{ session.user.display_name }}</span>
</button>
Expand All @@ -112,3 +127,4 @@ onBeforeUnmount(() => {
</NMessageProvider>
</NConfigProvider>
</template>

91 changes: 77 additions & 14 deletions frontend/src/api/meetings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { api, apiDownload } from './client'
import type { Meeting, MeetingParticipantWrite } from '../domain/meetings'
import { locale, type Locale } from '../i18n'

export type LifecycleAction = 'start' | 'finish' | 'cancel' | 'reopen'

Expand Down Expand Up @@ -75,11 +76,23 @@ export function rotateApiKey() {
})
}

export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: string): string {
const title = agendaTitle.trim() || '当前议题'
const OUTCOME_TAG_LINE_RE = /^@(?:决策|行动|开放问题|问题|Decision|decision|Action|action|Question|question|OpenQuestion):/
const BULLET_OUTCOME_TAG_RE = /^\s*[-*•]\s*(@(?:决策|行动|开放问题|问题|Decision|decision|Action|action|Question|question|OpenQuestion):.+)$/

export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: string, lang: Locale = locale.value): string {
const useEn = lang === 'en'
const title = agendaTitle.trim() || (useEn ? 'Current Agenda' : '当前议题')
const trimmed = rawMarkdown.trim()

if (!trimmed) {
if (useEn) {
return [
`### ${title} · Key Discussion Points`,
`@Decision: Confirm core implementation plan and rollout path for "${title}"`,
`@Action:[open] Complete API integration and automated regression tests for "${title}"`,
`@Question: Does the legacy data migration window for "${title}" require canary verification?`,
].join('\n')
}
return [
`### ${title} · 讨论要点`,
`@决策: 确认「${title}」核心实施方案与演进路径`,
Expand All @@ -93,9 +106,13 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
const existingTags = new Set<string>()
const extractedTags: string[] = []

const decPrefix = useEn ? '@Decision:' : '@决策:'
const actPrefix = useEn ? '@Action:' : '@行动:'
const qPrefix = useEn ? '@Question:' : '@开放问题:'

for (const originalLine of lines) {
const rawLine = originalLine.replace(/\\([[\]])/g, '$1')
const bulletTagMatch = rawLine.match(/^\s*[-*•]\s*(@(?:决策|行动|开放问题|问题):.+)$/)
const bulletTagMatch = rawLine.match(BULLET_OUTCOME_TAG_RE)
if (bulletTagMatch) {
const tagLine = bulletTagMatch[1].trim()
normalizedLines.push(tagLine)
Expand All @@ -104,7 +121,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
}

const stripped = rawLine.trim()
if (/^@(?:决策|行动|开放问题|问题):/.test(stripped)) {
if (OUTCOME_TAG_LINE_RE.test(stripped)) {
normalizedLines.push(stripped)
existingTags.add(stripped)
continue
Expand All @@ -113,28 +130,62 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
normalizedLines.push(rawLine)

const cleanLine = stripped.replace(/^[-*•\d.)\s]+/, '').trim()
if (!cleanLine || cleanLine.startsWith('#') || cleanLine.startsWith('```')) {
if (
!cleanLine
|| cleanLine.startsWith('#')
|| cleanLine.startsWith('```')
|| /[::]$/.test(cleanLine)
) {
continue
}

if (/(?:决定|确定|决议|统一采用|采用|拍板|一致同意|定稿|结论[::])/.test(cleanLine)) {
const content = cleanLine.replace(/^(?:团队一致决定|一致决定|决定|确定|决议|结论[::])\s*/, '').trim()
if (
/(?:待确认|疑问|是否|如何|待评估|[??]$)/.test(cleanLine)
|| /\b(?:question|tbd|whether|how to|need to confirm|open question)\b/i.test(cleanLine)
) {
const content = cleanLine
.replace(/^(?:待确认[::]?|疑问[::]?|问题[::]?|open question[::]?|question[::]?|tbd[::]?)\s*/i, '')
.trim()
if (content) {
const tag = `${qPrefix} ${content}`
if (!existingTags.has(tag)) extractedTags.push(tag)
}
} else if (
/^(?:请\s*@|待办|行动|action|todo)/i.test(cleanLine)
|| (/@[a-zA-Z0-9_-]+/.test(cleanLine) && /(?:请|负责|跟进|待办|完成|完善|推进|输出|补充|联调|验证|编写|提交)/.test(cleanLine))
) {
const content = cleanLine
.replace(/^(?:待办[::]?|行动[::]?|action[::]?|todo[::]?)\s*/i, '')
.trim()
if (content) {
const tag = `@决策: ${content}`
const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/)
const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : ''
const tag = `${actPrefix}[open]${ownerTag} ${content}`
if (!existingTags.has(tag)) extractedTags.push(tag)
}
} else if (/(?:待确认|疑问|是否|如何|待评估|[??]$)/.test(cleanLine)) {
const content = cleanLine.replace(/^(?:待确认[::]?|疑问[::]?|问题[::]?)\s*/, '').trim()
} else if (
/(?:决定|确定|决议|统一采用|采用|拍板|一致同意|定稿|结论[::])/.test(cleanLine)
|| /\b(?:decided|decide|agreed|agree|approved|approve|adopted|adopt|concluded|decision)\b/i.test(cleanLine)
|| /^(?:decision|conclusion)[::]/i.test(cleanLine)
) {
const content = cleanLine
.replace(/^(?:团队一致决定|一致决定|决定|确定|决议|结论[::]|decision[::]|conclusion[::])\s*/i, '')
.trim()
if (content) {
const tag = `@开放问题: ${content}`
const tag = `${decPrefix} ${content}`
if (!existingTags.has(tag)) extractedTags.push(tag)
}
} else if (/(?:负责|跟进|待办|完成|推进|输出|补充|联调|验证|编写|提交)/.test(cleanLine)) {
const content = cleanLine.replace(/^(?:待办[::]?|行动[::]?)\s*/, '').trim()
} else if (
/(?:负责|跟进|待办|完成|完善|推进|输出|补充|联调|验证|编写|提交)/.test(cleanLine)
|| /\b(?:will|todo|action|follow up|implement|complete|verify|test|prepare|submit|assign)\b/i.test(cleanLine)
) {
const content = cleanLine
.replace(/^(?:待办[::]?|行动[::]?|action[::]?|todo[::]?)\s*/i, '')
.trim()
if (content) {
const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/)
const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : ''
const tag = `@行动:[open]${ownerTag} ${content}`
const tag = `${actPrefix}[open]${ownerTag} ${content}`
if (!existingTags.has(tag)) extractedTags.push(tag)
}
}
Expand All @@ -146,6 +197,17 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
}

if (existingTags.size === 0) {
if (useEn) {
return [
normalizedLines.join('\n'),
'',
`@Decision: Confirm rollout plan for "${title}"`,
'',
`@Action:[open] Follow up on implementation and integration testing for "${title}"`,
'',
`@Question: Cross-module boundary details for "${title}" pending final confirmation`,
].join('\n')
}
return [
normalizedLines.join('\n'),
'',
Expand All @@ -160,3 +222,4 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri
return normalizedLines.join('\n')
}


Loading
Loading