diff --git a/packages/client-ui/src/components/common/BottomToast.test.ts b/packages/client-ui/src/components/common/BottomToast.test.ts index cf868ba..2dec186 100644 --- a/packages/client-ui/src/components/common/BottomToast.test.ts +++ b/packages/client-ui/src/components/common/BottomToast.test.ts @@ -1,5 +1,9 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' +import { nextTick } from 'vue' +import { createBottomToast } from '../../composables/useBottomToast' +import { setLocale, t } from '../../i18n' +import { bottomToastKey } from '../../runtimeKey' import BottomToast from './BottomToast.vue' describe('BottomToast', () => { @@ -10,4 +14,35 @@ describe('BottomToast', () => { expect(wrapper.get('[data-bottom-toast]').attributes('role')).toBe('status') expect(wrapper.get('[data-bottom-toast]').attributes('data-tone')).toBe('success') }) + + it('reactively translates visible bottom toast when locale toggles between zh and en', async () => { + setLocale('zh') + const controller = createBottomToast({ + now: () => 0, + setTimeout: () => 1, + clearTimeout: () => {}, + setInterval: () => 1, + clearInterval: () => {}, + }) + controller.show({ text: () => t('agent.chat.forbiddenToast'), tone: 'error' }) + + const wrapper = mount(BottomToast, { + global: { + provide: { + [bottomToastKey as symbol]: controller, + }, + }, + }) + + expect(wrapper.get('[data-bottom-toast]').text()).toBe('当前客户端没有访问该会话的权限,请重新登录授权。') + setLocale('en') + await nextTick() + try { + expect(wrapper.get('[data-bottom-toast]').text()).toBe('This client does not have permission to access this session. Please sign in and authorize again.') + } finally { + setLocale('zh') + await nextTick() + } + expect(wrapper.get('[data-bottom-toast]').text()).toBe('当前客户端没有访问该会话的权限,请重新登录授权。') + }) }) diff --git a/packages/client-ui/src/components/common/BottomToast.vue b/packages/client-ui/src/components/common/BottomToast.vue index c4f3b3e..295672f 100644 --- a/packages/client-ui/src/components/common/BottomToast.vue +++ b/packages/client-ui/src/components/common/BottomToast.vue @@ -11,6 +11,7 @@ diff --git a/packages/client-ui/src/composables/useAgentConversation.ts b/packages/client-ui/src/composables/useAgentConversation.ts index 116f571..0a41939 100644 --- a/packages/client-ui/src/composables/useAgentConversation.ts +++ b/packages/client-ui/src/composables/useAgentConversation.ts @@ -118,16 +118,16 @@ export function useAgentConversation(options: UseAgentConversationOptions) { } if (info.code === AGENT_STREAM_CLOSE_NOT_FOUND) { runtime.agentCursorStore.clear(options.conversationId) - toast.show({ text: t('agent.chat.notFoundToast'), tone: 'error' }) + toast.show({ text: () => t('agent.chat.notFoundToast'), tone: 'error' }) return } if (info.code === AGENT_STREAM_CLOSE_FORBIDDEN) { - toast.show({ text: t('agent.chat.forbiddenToast'), tone: 'error' }) + toast.show({ text: () => t('agent.chat.forbiddenToast'), tone: 'error' }) } }, onError: (error) => { if (error.code === 'stream_too_slow') { - toast.show({ text: t('agent.chat.recoveringToast'), tone: 'error' }) + toast.show({ text: () => t('agent.chat.recoveringToast'), tone: 'error' }) } }, onAuthenticationRequired: handleAuthenticationRequired, diff --git a/packages/client-ui/src/composables/useAgentConversations.ts b/packages/client-ui/src/composables/useAgentConversations.ts index 6a8c0dd..df2b273 100644 --- a/packages/client-ui/src/composables/useAgentConversations.ts +++ b/packages/client-ui/src/composables/useAgentConversations.ts @@ -7,7 +7,7 @@ import type { AgentConversationResponse } from '@termflow/client-contracts' import { ApiError, createApprovalsApi } from '@termflow/client-core' import { onBeforeUnmount, onMounted, ref, toValue, watch, type MaybeRefOrGetter } from 'vue' -import { t } from '../i18n' +import { locale, t, translateDynamicText } from '../i18n' import { useClientRuntime } from '../runtime' import { useBottomToast } from './useBottomToast' @@ -26,6 +26,11 @@ export function useAgentConversations(bindingId?: MaybeRefOrGetter const pendingCounts = ref>(new Map()) const loading = ref(true) const errorMessage = ref(null) + watch(locale, () => { + if (errorMessage.value !== null) { + errorMessage.value = translateDynamicText(errorMessage.value) + } + }) const approvalsApi = createApprovalsApi(runtime.api.request) // List loads and mutations keep separate controllers: load() aborts its // own in-flight list request on a binding switch, which must not cancel diff --git a/packages/client-ui/src/composables/useBottomToast.ts b/packages/client-ui/src/composables/useBottomToast.ts index c44f425..3cd3df7 100644 --- a/packages/client-ui/src/composables/useBottomToast.ts +++ b/packages/client-ui/src/composables/useBottomToast.ts @@ -1,4 +1,5 @@ -import { inject, ref, type Ref } from 'vue' +import { computed, inject, ref, type Ref } from 'vue' +import { locale, translateDynamicText } from '../i18n' import type { ClockPort } from '../runtime' import { bottomToastKey } from '../runtimeKey' @@ -9,25 +10,39 @@ export interface BottomToastMessage { readonly tone: BottomToastTone } +export interface BottomToastInput { + readonly text: string | (() => string) + readonly tone: BottomToastTone +} + export interface BottomToastController { readonly current: Readonly> - show(message: BottomToastMessage): void + show(message: BottomToastInput): void clear(): void } export function createBottomToast(clock: ClockPort): BottomToastController { - const current = ref(null) + const rawCurrent = ref(null) + const current = computed(() => { + void locale.value + if (rawCurrent.value === null) return null + const rawText = typeof rawCurrent.value.text === 'function' ? rawCurrent.value.text() : rawCurrent.value.text + return { + text: translateDynamicText(rawText), + tone: rawCurrent.value.tone, + } + }) let timer: unknown | null = null function clear() { if (timer !== null) clock.clearTimeout(timer) timer = null - current.value = null + rawCurrent.value = null } - function show(message: BottomToastMessage) { + function show(message: BottomToastInput) { clear() - current.value = message + rawCurrent.value = message timer = clock.setTimeout(() => { - current.value = null + rawCurrent.value = null timer = null }, 3_000) } diff --git a/packages/client-ui/src/i18n/index.ts b/packages/client-ui/src/i18n/index.ts index 007d35b..a4ec61d 100644 --- a/packages/client-ui/src/i18n/index.ts +++ b/packages/client-ui/src/i18n/index.ts @@ -297,6 +297,7 @@ const messages: Record> = { 'login.totpHint': '(请输入验证器应用当前显示的 6 位验证码。)', 'login.submit': '登录', 'login.verifyAndLogin': '验证并登录', + 'login.invalidToken': '管理员令牌无效,请核对后重试。', 'login.failed': '登录失败,请重试。', 'deviceAuth.eyebrow': '跨设备授权', 'deviceAuth.title': '授权新设备', @@ -825,6 +826,7 @@ const messages: Record> = { 'login.totpHint': ' (Enter the 6-digit code from your authenticator app.)', 'login.submit': 'Sign In', 'login.verifyAndLogin': 'Verify & Sign In', + 'login.invalidToken': 'Invalid admin token. Please check and try again.', 'login.failed': 'Sign-in failed. Please try again.', 'deviceAuth.eyebrow': 'Cross-device authorization', 'deviceAuth.title': 'Authorize New Device', @@ -1122,6 +1124,25 @@ export function t(key: string, params?: Record): string }) } +const reverseKeyMap = new Map() +for (const [key, val] of Object.entries(messages.zh)) { + if (!val.includes('{')) reverseKeyMap.set(val, key) +} +for (const [key, val] of Object.entries(messages.en)) { + if (!val.includes('{')) reverseKeyMap.set(val, key) +} + +export function translateDynamicText(text: string): string { + if (!text) return text + const dict = messages[currentLocale.value] ?? messages.zh + if (dict[text] !== undefined) return dict[text] + const matchedKey = reverseKeyMap.get(text) + if (matchedKey !== undefined && dict[matchedKey] !== undefined) { + return dict[matchedKey] + } + return text +} + export function useI18n() { const stored = readStoredLocale() if (stored !== null && currentLocale.value !== stored) { @@ -1133,5 +1154,6 @@ export function useI18n() { setLocale, toggleLocale, t, + translateDynamicText, } } diff --git a/packages/client-ui/src/views/AgentPreviewView.vue b/packages/client-ui/src/views/AgentPreviewView.vue index 6a52822..8dbf95e 100644 --- a/packages/client-ui/src/views/AgentPreviewView.vue +++ b/packages/client-ui/src/views/AgentPreviewView.vue @@ -10,6 +10,17 @@
{{ t('agent.backend.busy') }} +
@@ -50,7 +61,7 @@ import AgentApprovalCard from '../components/agent/AgentApprovalCard.vue' import AgentPartsList, { type AgentConversationPart } from '../components/agent/AgentPartsList.vue' import MobileKeyBar from '../components/terminal/MobileKeyBar.vue' import ThemePicker from '../components/settings/ThemePicker.vue' -import { Send } from '@lucide/vue' +import { Globe, Send } from '@lucide/vue' import { useSensitiveAuthorization } from '../composables/useSensitiveAuthorization' import { useI18n } from '../i18n' import { @@ -62,7 +73,7 @@ import { type AguiEvent, } from '@termflow/client-core' -const { t } = useI18n() +const { t, locale, toggleLocale } = useI18n() const authorization = useSensitiveAuthorization() const modifierController = new MobileModifierController() const lastKeyInput = ref('') @@ -70,56 +81,61 @@ function handleKeyInput(bytes: Uint8Array) { lastKeyInput.value = new TextDecoder().decode(bytes) } const now = Date.now() -let state = createAgentHistoryState() -state = seedUserMessages( - state, - [ +const history = computed(() => { + void locale.value + let state = createAgentHistoryState() + state = seedUserMessages( + state, + [ + { + message_id: 'u1', + conversation_id: 'c1', + run_id: null, + role: 'user', + kind: 'text', + assembly_revision: 1, + is_final: false, + body_digest: 'd', + body: t('agent.preview.sampleUserPrompt'), + created_at: new Date(now).toISOString(), + }, + ] as never, + () => now, + ) + + const frames: AguiEvent[] = [ + { type: 'RUN_STARTED', threadId: 'c1', runId: 'r1' }, + { type: 'STATE_DELTA', delta: [{ op: 'replace', path: '/backend', value: { state: 'busy', epoch: 1 } }] }, + { type: 'CUSTOM', name: 'termflow.thinking_delta', value: { id: 'th1', delta: t('agent.preview.sampleThinking1') } }, + { type: 'CUSTOM', name: 'termflow.thinking_completed', value: { id: 'th1' } }, + { type: 'TEXT_MESSAGE_CHUNK', messageId: 'a1', delta: t('agent.preview.sampleAssistant1') }, + { type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'termflow_termflow_list_panes' }, { - message_id: 'u1', - conversation_id: 'c1', - run_id: null, - role: 'user', - kind: 'text', - assembly_revision: 1, - is_final: false, - body_digest: 'd', - body: t('agent.preview.sampleUserPrompt'), - created_at: new Date(now).toISOString(), + type: 'TOOL_CALL_RESULT', + messageId: 't1', + toolCallId: 't1', + content: '{"status":"success","truncated":false}', }, - ] as never, - () => now, -) - -const frames: AguiEvent[] = [ - { type: 'RUN_STARTED', threadId: 'c1', runId: 'r1' }, - { type: 'STATE_DELTA', delta: [{ op: 'replace', path: '/backend', value: { state: 'busy', epoch: 1 } }] }, - { type: 'CUSTOM', name: 'termflow.thinking_delta', value: { id: 'th1', delta: t('agent.preview.sampleThinking1') } }, - { type: 'CUSTOM', name: 'termflow.thinking_completed', value: { id: 'th1' } }, - { type: 'TEXT_MESSAGE_CHUNK', messageId: 'a1', delta: t('agent.preview.sampleAssistant1') }, - { type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'termflow_termflow_list_panes' }, - { - type: 'TOOL_CALL_RESULT', - messageId: 't1', - toolCallId: 't1', - content: '{"status":"success","truncated":false}', - }, - { type: 'TEXT_MESSAGE_END', messageId: 'a1' }, - { type: 'TEXT_MESSAGE_CHUNK', messageId: 'a2', delta: t('agent.preview.sampleAssistant2') }, - { - type: 'CUSTOM', - name: 'termflow.permission_requested', - value: { approval_request_id: 'a0000000-0000-4000-8000-000000000001' }, - }, -] as AguiEvent[] - -for (const frame of frames) state = applyAguiEvent(state, frame, () => now) + { type: 'TEXT_MESSAGE_END', messageId: 'a1' }, + { type: 'TEXT_MESSAGE_CHUNK', messageId: 'a2', delta: t('agent.preview.sampleAssistant2') }, + { + type: 'CUSTOM', + name: 'termflow.permission_requested', + value: { approval_request_id: 'a0000000-0000-4000-8000-000000000001' }, + }, + ] as AguiEvent[] -const history: AgentHistoryState = state -const parts = computed(() => [ - { type: 'step-start' }, - { type: 'reasoning', text: t('agent.preview.sampleThinking1') }, - { type: 'tool', tool: 'termflow_termflow_list_panes', status: 'completed', input: '{}', output: '{\n "instance_id": "a38adddd-f204-4bd8-9587-0b57aa21524c",\n "panes": [{"pane_id": "%0", "active": true, "dead": false}]\n}' }, - { type: 'reasoning', text: t('agent.preview.sampleThinking2') }, + for (const frame of frames) state = applyAguiEvent(state, frame, () => now) + return state +}) +void history.value +const parts = computed(() => { + void locale.value + return [ + { type: 'step-start' }, + { type: 'reasoning', text: t('agent.preview.sampleThinking1') }, + { type: 'tool', tool: 'termflow_termflow_list_panes', status: 'completed', input: '{}', output: '{\n "instance_id": "a38adddd-f204-4bd8-9587-0b57aa21524c",\n "panes": [{"pane_id": "%0", "active": true, "dead": false}]\n}' }, + { type: 'reasoning', text: t('agent.preview.sampleThinking2') }, { type: 'tool', tool: 'termflow_termflow_git_diff', @@ -149,7 +165,8 @@ const parts = computed(() => [ { type: 'tool', tool: 'termflow_termflow_pane_read', status: 'completed', input: '{"params": {"pane_id": "%0"}}', output: '"$ echo 1\\n1\\n$ "' }, { type: 'text', text: t('agent.preview.sampleFinalText') }, { type: 'step-finish' }, -]) + ] +}) const permission = { approvalId: 'a0000000-0000-4000-8000-000000000001', toolName: 'rm', diff --git a/packages/client-ui/src/views/AgentView.vue b/packages/client-ui/src/views/AgentView.vue index c45d222..eb9feeb 100644 --- a/packages/client-ui/src/views/AgentView.vue +++ b/packages/client-ui/src/views/AgentView.vue @@ -162,7 +162,7 @@ import { useBottomToast } from '../composables/useBottomToast' import { useI18n } from '../i18n' import { useClientRuntime } from '../runtime' -const { t } = useI18n() +const { t, locale, translateDynamicText } = useI18n() const runtime = useClientRuntime() const toast = useBottomToast() const { agentBrokerEnabled } = useAgentBroker() @@ -171,6 +171,11 @@ const bindings = ref([]) const termNames = ref(new Map()) const directoryLoading = ref(true) const directoryError = ref(null) +watch(locale, () => { + if (directoryError.value !== null) { + directoryError.value = translateDynamicText(directoryError.value) + } +}) const controller = ref(null) const conversations = useAgentConversations(() => bindings.value.map((binding) => binding.binding_id)) diff --git a/packages/client-ui/src/views/LoginView.test.ts b/packages/client-ui/src/views/LoginView.test.ts index e42dcb8..16c3df0 100644 --- a/packages/client-ui/src/views/LoginView.test.ts +++ b/packages/client-ui/src/views/LoginView.test.ts @@ -118,4 +118,25 @@ describe('LoginView', () => { } expect(wrapper.get('[role="alert"]').text()).toBe('会话已过期,请重新登录。') }) + + it('displays invalid admin token error on HTTP 401 unauthorized login failure across zh and en', async () => { + const login = vi.fn().mockRejectedValue(new ApiError('authentication', { status: 401, code: 'unauthorized' })) + const runtime = createFakeRuntime({ api: { sessions: { login } } as unknown as ClientRuntime['api'] }) + const { wrapper } = await mountLogin(runtime) + + await wrapper.get('input[type="password"]').setValue('wrong-token') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('管理员令牌无效,请核对后重试。') + + const toggleBtn = wrapper.get('[data-action="toggle-locale"]') + await toggleBtn.trigger('click') + await flushPromises() + try { + expect(wrapper.get('[role="alert"]').text()).toBe('Invalid admin token. Please check and try again.') + } finally { + await toggleBtn.trigger('click') + await flushPromises() + } + }) }) diff --git a/packages/client-ui/src/views/LoginView.vue b/packages/client-ui/src/views/LoginView.vue index 9b8a2bc..2fead5c 100644 --- a/packages/client-ui/src/views/LoginView.vue +++ b/packages/client-ui/src/views/LoginView.vue @@ -76,7 +76,15 @@ async function submit() { const requested = typeof route.query.redirect === 'string' ? route.query.redirect : '/' await router.replace(requested.startsWith('/') && !requested.startsWith('//') ? requested : '/') } catch (error) { - errorState.value = error instanceof ApiError ? { type: 'api', kind: error.kind } : { type: 'key', key: 'login.failed' } + if (error instanceof ApiError) { + if (challengeId.value === null && error.kind === 'authentication' && (error.status === 401 || error.code === 'unauthorized')) { + errorState.value = { type: 'key', key: 'login.invalidToken' } + } else { + errorState.value = { type: 'api', kind: error.kind } + } + } else { + errorState.value = { type: 'key', key: 'login.failed' } + } } finally { adminToken.value = '' totpCode.value = ''