@@ -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 = ''