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
8 changes: 4 additions & 4 deletions backend/app/agendas/outcome_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ def parse_action_metadata(
due_date_val = date.fromisoformat(token)
except ValueError:
break
elif token_lower in ("todo", "open"):
elif token_lower in ("todo", "open", "待办", "待开始"):
status = ActionStatus.open
elif token_lower in ("in_progress", "doing", "progress"):
elif token_lower in ("in_progress", "doing", "progress", "进行中"):
status = ActionStatus.in_progress
elif token_lower in ("done", "completed", "finish", "finished"):
elif token_lower in ("done", "completed", "finish", "finished", "已完成", "完成"):
status = ActionStatus.done
elif token_lower in ("canceled", "cancelled"):
elif token_lower in ("canceled", "cancelled", "已取消", "取消"):
status = ActionStatus.canceled
else:
break
Expand Down
34 changes: 34 additions & 0 deletions backend/tests/domain/test_bidirectional_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,40 @@ def test_parse_outcome_tags_extended_syntax():
assert actions[3].content == "传统无括号待办"


def test_parse_outcome_tags_chinese_status_tokens():
md = """
@行动:[待办][@林宇][2026-10-01] 整理压测报告
@行动:[待开始][@乔安] 准备回滚预案
@行动:[进行中][@林宇] 推进网关限流改造
@行动:[已完成][@乔安][2026-09-30] 完成发布清单核验
@行动:[已取消] 旧版接口迁移计划
"""
tags = parse_outcome_tags(md)
assert len(tags) == 5

assert tags[0].status == ActionStatus.open
assert tags[0].owner_name == "林宇"
assert tags[0].due_date == date(2026, 10, 1)
assert tags[0].content == "整理压测报告"

assert tags[1].status == ActionStatus.open
assert tags[1].owner_name == "乔安"
assert tags[1].content == "准备回滚预案"

assert tags[2].status == ActionStatus.in_progress
assert tags[2].owner_name == "林宇"
assert tags[2].content == "推进网关限流改造"

assert tags[3].status == ActionStatus.done
assert tags[3].owner_name == "乔安"
assert tags[3].due_date == date(2026, 9, 30)
assert tags[3].content == "完成发布清单核验"

assert tags[4].status == ActionStatus.canceled
assert tags[4].owner_name is None
assert tags[4].content == "旧版接口迁移计划"


def test_rewrite_action_in_notes():
notes = """# Notes
- List item
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.
8 changes: 7 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { locale } from '../i18n'
import { translateBackendError } from '../utils/errors'

export class ApiError extends Error {
constructor(
Expand All @@ -25,7 +26,12 @@ export function apiErrorMessage(
&& 'message' in payload.error
&& typeof payload.error.message === 'string'
) {
return payload.error.message
const code = 'code' in payload.error && typeof payload.error.code === 'string' ? payload.error.code : null
const translated = translateBackendError(payload.error.message, code)
if (locale.value === 'en' && /[\u4e00-\u9fff]/.test(translated) && !/[\u4e00-\u9fff]/.test(fallback)) {
return fallback
}
return translated
}
return fallback
}
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/api/plugin-stream.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { locale } from '../i18n'
import { translateBackendError } from '../utils/errors'
import { apiErrorMessage, dispatchAuthExpired } from './client'

function consumeEvent(block: string, onDelta: (text: string) => void): boolean {
Expand All @@ -24,7 +25,8 @@ function consumeEvent(block: string, onDelta: (text: string) => void): boolean {
}
if (event === 'error') {
if (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string') {
throw new Error(payload.message)
const code = 'code' in payload && typeof payload.code === 'string' ? payload.code : null
throw new Error(translateBackendError(payload.message, code))
}
throw new Error(locale.value === 'en' ? 'Failed to generate AI work brief, please try again later' : 'AI 工作简报生成失败,请稍后重试')
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/AgendaDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ const noteActionTags = computed(() => {
<template>
<div class="agenda-detail" data-testid="agenda-detail">
<header class="agenda-detail-header">
<div><p class="eyebrow">Current topic</p><input v-model="draft.title" class="agenda-title-input" :aria-label="tr('议题标题', 'Agenda Title')" :readonly="!canContribute" /></div>
<div><p class="eyebrow">{{ tr('当前议题', 'Current topic') }}</p><input v-model="draft.title" class="agenda-title-input" :aria-label="tr('议题标题', 'Agenda Title')" :readonly="!canContribute" /></div>
<div class="agenda-detail-status">
<StatusPill :status="props.item.status" kind="agenda" />
<n-tag v-if="item.carry_from_open_question_id" size="small" :bordered="false">{{ tr('来自开放问题', 'From Question') }}</n-tag>
Expand Down Expand Up @@ -532,7 +532,7 @@ const noteActionTags = computed(() => {
<n-button v-if="canCopy" quaternary size="small" @click="openCopy">{{ tr('复制到其他会议', 'Copy to Meeting') }}</n-button>
</div>

<section class="agenda-outcomes"><header class="section-heading"><div><p class="eyebrow">Outcomes</p><h2>{{ tr('本议题产出', 'Topic Outcomes') }}</h2></div><div v-if="canContribute" class="outcome-action-group" data-testid="outcome-actions"><button class="button button-small button-quiet" @click="composer = 'decision'">{{ tr('+ 决策', '+ Decision') }}</button><button class="button button-small button-quiet" @click="composer = 'action'">{{ tr('+ 行动', '+ Action') }}</button><button class="button button-small button-quiet" @click="composer = 'question'">{{ tr('+ 开放问题', '+ Question') }}</button></div></header>
<section class="agenda-outcomes"><header class="section-heading"><div><p class="eyebrow">{{ tr('议题产出', 'Outcomes') }}</p><h2>{{ tr('本议题产出', 'Topic Outcomes') }}</h2></div><div v-if="canContribute" class="outcome-action-group" data-testid="outcome-actions"><button class="button button-small button-quiet" @click="composer = 'decision'">{{ tr('+ 决策', '+ Decision') }}</button><button class="button button-small button-quiet" @click="composer = 'action'">{{ tr('+ 行动', '+ Action') }}</button><button class="button button-small button-quiet" @click="composer = 'question'">{{ tr('+ 开放问题', '+ Question') }}</button></div></header>
<OutcomeComposer v-if="canContribute && composer" :mode="composer" :meeting="meeting" :item="item" @close="composer = null" @saved="emit('changed')" />
<div class="outcome-list">
<article
Expand Down Expand Up @@ -603,7 +603,7 @@ const noteActionTags = computed(() => {
</section>

<section class="agenda-attachments" data-testid="agenda-attachments">
<header class="section-heading"><div><p class="eyebrow">Attachments</p><h2>{{ tr('议题附件', 'Attachments') }}</h2></div></header>
<header class="section-heading"><div><p class="eyebrow">{{ tr('相关附件', 'Attachments') }}</p><h2>{{ tr('议题附件', 'Attachments') }}</h2></div></header>
<AttachmentPanel target-type="agenda_item" :target-id="item.id" :attachments="attachments" :can-contribute="canContribute" @uploaded="onAttachmentUploaded" @deleted="onAttachmentDeleted" />
</section>

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AgendaQueue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ function onMenuSelect(item: AgendaItem, key: string) {

<template>
<aside class="agenda-queue" data-testid="agenda-queue">
<header class="section-heading"><div><p class="eyebrow">Agenda</p><h2>{{ tr('议题队列', 'Agenda Queue') }}</h2></div><button v-if="canContribute" class="button button-small button-primary" @click="adding = !adding">{{ adding ? tr('收起', 'Close') : tr('+ 议题', '+ Agenda') }}</button></header>
<header class="section-heading"><div><p class="eyebrow">{{ tr('议程编排', 'Agenda') }}</p><h2>{{ tr('议题队列', 'Agenda Queue') }}</h2></div><button v-if="canContribute" class="button button-small button-primary" @click="adding = !adding">{{ adding ? tr('收起', 'Close') : tr('+ 议题', '+ Agenda') }}</button></header>
<form v-if="canContribute && adding" class="agenda-add-form" @submit.prevent="addAgenda">
<label>{{ tr('议题标题', 'Agenda Title') }}<input v-model="title" required /></label>
<label>{{ tr('类型', 'Type') }}<select v-model="agendaType"><option value="information">{{ tr('信息同步', 'Info Sync') }}</option><option value="discussion">{{ tr('讨论', 'Discussion') }}</option><option value="decision">{{ tr('决策', 'Decision') }}</option></select></label>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AgendaWorkbench.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ defineExpose({ flushCurrentDraft })
<Transition name="agenda-detail" mode="out-in">
<AgendaDetail v-if="selected" :key="selected.id" ref="detail" :meeting="meeting" :item="selected" :can-contribute="canContribute" @changed="emit('reload')" @advance="advance" />
<div v-else class="agenda-empty-compact" data-testid="agenda-detail">
<div><p class="eyebrow">Current topic</p><h2>{{ tr('还没有议题', 'No agenda items yet') }}</h2><p>{{ tr('从右侧队列添加本次会议的第一个议题。', 'Add the first agenda item for this meeting from the queue on the right.') }}</p></div>
<div><p class="eyebrow">{{ tr('当前议题', 'Current topic') }}</p><h2>{{ tr('还没有议题', 'No agenda items yet') }}</h2><p>{{ tr('从右侧队列添加本次会议的第一个议题。', 'Add the first agenda item for this meeting from the queue on the right.') }}</p></div>
</div>
</Transition>
<AgendaQueue :meeting="meeting" :can-contribute="canContribute" :selected-id="selectedId" :opening-id="openingId" :open-error="openError" @select="openAgenda" @changed="emit('reload')" />
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/CompletedMeetingChain.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ async function addAmendment() {
<div class="completed-chain">
<n-card class="completed-summary">
<header class="section-heading">
<div><p class="eyebrow">Trusted record</p><h2>{{ tr('会议完成链条', 'Completed Meeting Record') }}</h2></div>
<div><p class="eyebrow">{{ tr('可信档案', 'Trusted record') }}</p><h2>{{ tr('会议完成链条', 'Completed Meeting Record') }}</h2></div>
<div v-if="canContribute" class="page-header-actions"><button class="button button-quiet" @click="amendmentOpen = !amendmentOpen">{{ tr('添加更正', 'Add Amendment') }}</button></div>
</header>
<p class="snapshot-meta">{{ tr(`快照 #${meeting.current_snapshot?.completion_number ?? '—'} · 原始记录保持只读`, `Snapshot #${meeting.current_snapshot?.completion_number ?? '—'} · Original record is read-only`) }}</p>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/OutcomeComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { api } from '../api/client'
import type { AgendaItem, Meeting } from '../domain/meetings'
import type { ActionPriority } from '../domain/outcomes'
import { useI18n } from '../i18n'
import { errorMessage } from '../utils/errors'
import MarkdownEditor from './MarkdownEditor.vue'
import PluginEditorSlot from './PluginEditorSlot.vue'

Expand Down Expand Up @@ -46,7 +47,7 @@ async function save() {
emit('saved')
emit('close')
} catch (caught) {
error.value = caught instanceof Error ? caught.message : tr(`${labels.value}保存失败`, `Failed to save ${labels.value}`)
error.value = errorMessage(caught, tr(`${labels.value}保存失败`, `Failed to save ${labels.value}`))
} finally {
saving.value = false
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ProjectActivityTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ onMounted(() => {

<template>
<section class="workspace-section project-activity-tab">
<header class="section-heading"><div><p class="eyebrow">Activity</p><h2>{{ tr('项目动态', 'Project Activity') }}</h2></div></header>
<header class="section-heading"><div><p class="eyebrow">{{ tr('动态流', 'Activity') }}</p><h2>{{ tr('项目动态', 'Project Activity') }}</h2></div></header>

<ProjectUpdateComposer v-if="canContribute" :project-id="project.id" :health="project.health" @saved="onComposerSaved" />

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ProjectOverview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ onMounted(async () => {
<section class="workspace-section project-dashboard-card">
<div class="section-heading">
<div>
<p class="eyebrow">Project state</p>
<p class="eyebrow">{{ tr('状态概览', 'Project state') }}</p>
<h2>{{ tr('项目状态', 'Project State') }}</h2>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ProjectUpdateComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function submit() {
<template>
<form class="project-update-composer" @submit.prevent="submit">
<div class="composer-heading">
<div><p class="eyebrow">Progress update</p><h3>{{ tr('追加项目进展', 'Post Project Update') }}</h3></div>
<div><p class="eyebrow">{{ tr('进展更新', 'Progress update') }}</p><h3>{{ tr('追加项目进展', 'Post Project Update') }}</h3></div>
<label>{{ tr('健康度', 'Health') }}<select v-model="health"><option value="on_track">{{ tr('进展正常', 'On Track') }}</option><option value="at_risk">{{ tr('存在风险', 'At Risk') }}</option><option value="off_track">{{ tr('偏离计划', 'Off Track') }}</option><option value="unset">{{ tr('未设置', 'Unset') }}</option></select></label>
</div>
<PluginEditorSlot :editor-label="tr('进展记录', 'Progress Update')" v-model="content" data-testid="project-update-editor" target-type="project" :target-id="projectId" slot="project-update-editor" :metadata="{ projectId }" @notice="error = $event">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/composables/useVersionedSave.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ref, type Ref } from 'vue'

import { ApiError } from '../api/client'
import { errorMessage } from '../utils/errors'

/**
* Shared optimistic-concurrency primitive for entities saved with `expected_version`.
Expand Down Expand Up @@ -42,7 +43,7 @@ export function useVersionedSave<T>(
}
return null
}
error.value = reason instanceof Error ? reason.message : String(reason)
error.value = errorMessage(reason, String(reason))
return null
} finally {
saving.value = false
Expand Down
23 changes: 21 additions & 2 deletions frontend/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'

export type Locale = 'zh' | 'en'

Expand All @@ -16,20 +16,39 @@ function readInitialLocale(): Locale {
return 'zh'
}

function syncDocumentLocale(next: Locale): void {
try {
if (typeof document !== 'undefined' && document.documentElement) {
document.documentElement.lang = next
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('meetflow:lang-change', { detail: { lang: next } }))
}
} catch {
// Ignore DOM/event errors in restricted environments.
}
}

export const locale = ref<Locale>(readInitialLocale())
syncDocumentLocale(locale.value)

watch(locale, (next) => {
syncDocumentLocale(next)
})

export const isEn = computed(() => locale.value === 'en')

export function setLocale(lang: Locale): void {
const next: Locale = lang === 'en' ? 'en' : 'zh'
locale.value = next
try {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(STORAGE_KEY, next)
}
} catch {
// Ignore storage write errors.
}
locale.value = next
syncDocumentLocale(next)
}

export function toggleLocale(): Locale {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/tests/agenda-workbench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe('agenda workbench', () => {
expect(workbench).toContainElement(detail)
expect(workbench).toContainElement(queue)
expect(detail).toHaveClass('agenda-empty-compact')
expect(within(detail).getByText('Current topic')).toBeVisible()
expect(within(detail).getByText('当前议题')).toBeVisible()
expect(detail.compareDocumentPosition(queue) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(within(queue).getByRole('button', { name: '+ 议题' })).toBeVisible()
expect(within(detail).queryByRole('button', { name: '添加议题' })).not.toBeInTheDocument()
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/tests/ai-work-assistant-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,27 @@ it('does not mutate editor content when a job fails', async () => {
expect(emitted().draft).toBeUndefined()
expect(emitted().notice).toEqual([['模型不可用']])
})

it('re-renders mounted assistant controls reactively when language switches between zh and en', async () => {
const { setLocale } = await import('../i18n')
setLocale('zh')
const registered = registerAssistant()
renderAssistant(registered, 'meeting-summary-editor')

expect(screen.getByText('AI 协助纪要')).toBeInTheDocument()
expect(screen.getByText('当前编辑块')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '生成会议纪要' })).toBeInTheDocument()

setLocale('en')
await nextTick()

expect(screen.getByText('AI Summary Assistant')).toBeInTheDocument()
expect(screen.getByText('Current Block')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Generate Meeting Minutes' })).toBeInTheDocument()

setLocale('zh')
await nextTick()

expect(screen.getByText('AI 协助纪要')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '生成会议纪要' })).toBeInTheDocument()
})
26 changes: 24 additions & 2 deletions frontend/src/tests/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { api, apiDownload, ApiError } from '../api/client'
import { api, apiDownload, ApiError, apiErrorMessage } from '../api/client'
import { setLocale } from '../i18n'
import { errorMessage, translateBackendError } from '../utils/errors'

describe('api client', () => {
afterEach(() => vi.unstubAllGlobals())
afterEach(() => {
setLocale('zh')
vi.unstubAllGlobals()
})

it('sends cookies and JSON content type', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
Expand Down Expand Up @@ -68,4 +73,21 @@ describe('api client', () => {
expect(result.filename).toBe('meeting notes.md')
expect(result.blob.size).toBe(5)
})

it('translates backend Chinese errors and worker diagnostics in en mode', () => {
setLocale('en')

expect(apiErrorMessage({ error: { code: 'invalid_credentials', message: '用户名或密码错误' } }))
.toBe('Incorrect username or password')
expect(apiErrorMessage({ error: { code: 'validation_error', message: '请求参数无效' } }))
.toBe('Invalid request parameters')
expect(apiErrorMessage({ error: { code: 'meeting_locked', message: '已结束的会议不可直接修改' } }))
.toBe('Meeting is locked')
expect(translateBackendError('认证或权限失败;检查管理员插件设置中的 API Key、服务地址和权限。', 'provider_auth_failed'))
.toBe('Authentication or permission failed; check API Key, endpoint URL, and permissions in plugin settings.')
expect(translateBackendError('插件清单无效', 'ManifestError'))
.toBe('Invalid plugin manifest')
expect(errorMessage(new Error('未知后端中文错误'), 'Sign in failed'))
.toBe('Sign in failed')
})
})
Loading
Loading