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
90 changes: 90 additions & 0 deletions backend/tests/test_assistant_welcome_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Exercise the actual UI schema and save handler with an isolated session.

Empty welcome strings must overwrite old custom text; omitted fields must not.
This does not exercise FastAPI authentication, file uploads or a real database.
"""

import ast
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Optional
from unittest.mock import AsyncMock, Mock

import pytest
from pydantic import BaseModel, Field

ROOT = Path(__file__).resolve().parents[1]


def load_node(path, name, namespace):
node = next(
n for n in ast.parse(path.read_text()).body if getattr(n, "name", None) == name
)
if isinstance(node, ast.AsyncFunctionDef):
node.decorator_list = []
exec(
compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace
)


@pytest.mark.parametrize(
"fields,expected",
[
({"welcome": "", "welcome_desc": ""}, {"welcome": "", "welcome_desc": ""}),
(
{"welcome": "New custom title"},
{"welcome": "New custom title", "welcome_desc": "Custom description"},
),
(
{"theme": "#123456"},
{"welcome": "Custom title", "welcome_desc": "Custom description"},
),
],
)
def test_save_welcome_defaults(fields, expected):
model = SimpleNamespace(
id=42,
configuration=json.dumps(
{
"welcome": "Custom title",
"welcome_desc": "Custom description",
"auto_ds": True,
}
),
)
namespace = {
"BaseModel": BaseModel,
"Field": Field,
"Optional": Optional,
"List": list,
"json": json,
"SessionDep": object,
"CurrentUser": object,
"Trans": object,
"UploadFile": object,
"Form": lambda: None,
"get_ws_assistant": Mock(return_value=model),
"clear_ui_cache": AsyncMock(),
}
load_node(ROOT / "common/core/schemas.py", "BaseCreatorDTO", namespace)
load_node(
ROOT / "apps/system/schemas/system_schema.py", "AssistantUiSchema", namespace
)
load_node(ROOT / "apps/system/api/assistant.py", "ui", namespace)
session, user, trans = Mock(), object(), object()
result = asyncio.run(
namespace["ui"](
session, user, trans, json.dumps({"id": 42, **fields}), files=[]
)
)
stored = json.loads(result.configuration)
assert {key: stored[key] for key in expected} == expected
assert stored["auto_ds"] is True
if "theme" in fields:
assert stored["theme"] == fields["theme"]
namespace["get_ws_assistant"].assert_called_once_with(session, 42, user, trans)
session.add.assert_called_once_with(model)
session.commit.assert_called_once()
namespace["clear_ui_cache"].assert_awaited_once_with(42)
15 changes: 15 additions & 0 deletions docs/agents/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@

可以用脚本比较键路径,但不要为了普通文案变更引入新依赖。

## 欢迎语与恢复默认

- 助手的 `welcome` / `welcome_desc` 和全局外观的 `pc_welcome` / `pc_welcome_desc` / `slogan`
使用空字符串表示默认文案。保存接口保留空字符串;不要改为省略字段或传 `null`,
助手 PATCH 会忽略这两种输入,无法覆盖旧配置。
- 显示和编辑入口共用 `frontend/src/i18n/welcome.ts`。默认文案按当前 locale 解析;
编辑框用翻译后的默认文案作为 placeholder,预览显示解析结果,恢复默认后保存空字符串。
- 兼容旧编辑器保存的默认文本:仅当内容与五种语言的系统默认文案完全一致时,
按默认模式处理,包括两种历史说明文案。其余自定义文本原样保留,不 trim 或自动翻译。
历史数据没有来源标记,因此手工输入且恰好等于系统默认文案的内容也按默认处理。
- 普通问数首页和页面嵌入共用全局外观文案;基础、高级助手的浮窗和全屏共用助手文案。
修改时覆盖这些入口及恢复默认、保存、重新打开、切换语言的链路。
- 外观设置的登录区域与平台区域分别恢复各自的默认值。登录欢迎语 `slogan` 的预览和
实际登录页也按上述规则解析;是否显示仍由 `showSlogan` 开关控制。

## 新增语言

新增语言不是只添加 JSON 文件。以提交 `21961e2d`(Thai / `th-TH`)为完整示例,逐项检查:
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
"invalid_embedding_parameter": "유사도는 0~1 사이의 유한한 수여야 하며 후보 수는 양의 정수여야 합니다."
},
"prompt": {
"default_password:": "기본 비밀번호:{msg}",
"default_password": "기본 비밀번호:{msg}",
"no_sql_sample": "현재 사용 가능한 SQL 샘플 라이브러리가 없습니다",
"add_sql_sample": "SQL 샘플 라이브러리를 추가하세요",
"no_prompt_words": "프롬프트가 없습니다",
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/i18n/welcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import en from './en.json'
import zhCN from './zh-CN.json'
import zhTW from './zh-TW.json'
import koKR from './ko-KR.json'
import thTH from './th-TH.json'

type WelcomeKey =
| 'embedded.i_am_sqlbot'
| 'embedded.data_analysis_now'
| 'qa.hint_description'
| 'common.intelligent_questioning_platform'

const messages = [en, zhCN, zhTW, koKR, thTH]
const defaultTitles = new Set(messages.map((message) => message.embedded.i_am_sqlbot))
const defaultDescriptions = new Set(
messages.flatMap((message) => [message.embedded.data_analysis_now, message.qa.hint_description])
)
const defaultSlogans = new Set(
messages.map((message) => message.common.intelligent_questioning_platform)
)
const defaultsByKey: Record<WelcomeKey, Set<string>> = {
'embedded.i_am_sqlbot': defaultTitles,
'embedded.data_analysis_now': defaultDescriptions,
'qa.hint_description': defaultDescriptions,
'common.intelligent_questioning_platform': defaultSlogans,
}

// An empty string means "follow the display language" in both existing save APIs.
// Older editors persisted translated defaults. Match only exact stock strings;
// never trim, translate or otherwise rewrite a user's custom text.
export function normalizeWelcome(value: string | null | undefined, key: WelcomeKey): string {
const defaults = defaultsByKey[key]
return value == null || defaults.has(value) ? '' : value
}

export function resolveWelcome(
value: string | null | undefined,
key: WelcomeKey,
translate: (key: WelcomeKey) => string
): string {
return normalizeWelcome(value, key) || translate(key)
}
4 changes: 2 additions & 2 deletions frontend/src/i18n/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -883,8 +883,8 @@
"version_num": "版本號",
"standard": "社區版",
"enterprise": "企業版",
"professional": "專業版",
"embedded": "嵌入式版",
"Professional": "專業版",
"Embedded": "嵌入式版",
"support": "取得技術支援",
"update_success": "更新成功,請重新登入",
"serial_no": "序號",
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/views/chat/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,10 @@
><custom_small v-if="appearanceStore.themeColor !== 'default'"></custom_small>
<LOGO_fold v-else></LOGO_fold
></el-icon>
<span>{{ appearanceStore.pc_welcome ?? '你好,我是 SQLBot' }}</span>
<span>{{ pageWelcome }}</span>
</div>
<div class="sub">
{{
appearanceStore.pc_welcome_desc ??
'我可以查询数据、生成图表、检测数据异常、预测数据等赶快开启智能问数吧~'
}}
{{ pageWelcomeDesc }}
</div>
</template>

Expand Down Expand Up @@ -467,6 +464,7 @@ import ErrorInfo from './ErrorInfo.vue'
import ChatToolBar from './ChatToolBar.vue'
import { dsTypeWithImg } from '@/views/ds/js/ds-type'
import { useI18n } from 'vue-i18n'
import { resolveWelcome } from '@/i18n/welcome'
import { find, forEach } from 'lodash-es'
import custom_small from '@/assets/svg/logo-custom_small.svg'
import LOGO_fold from '@/assets/LOGO-fold.svg'
Expand Down Expand Up @@ -519,6 +517,12 @@ const customName = computed(() => {
return ''
})
const { t } = useI18n()
const pageWelcome = computed(() =>
resolveWelcome(appearanceStore.pc_welcome, 'embedded.i_am_sqlbot', t)
)
const pageWelcomeDesc = computed(() =>
resolveWelcome(appearanceStore.pc_welcome_desc, 'qa.hint_description', t)
)

const chatConfig = useChatConfigStore()

Expand Down
40 changes: 9 additions & 31 deletions frontend/src/views/embedded/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,16 @@
<chat-component
v-if="!loading"
ref="chatRef"
:welcome="customSet.welcome"
:welcome-desc="customSet.welcome_desc"
:welcome="welcome"
:welcome-desc="welcomeDesc"
:logo-assistant="logo"
/>
</div>
<div class="drawer-assistant" @click="openHistory"></div>
</div>
</template>
<script setup lang="ts">
import {
onBeforeMount,
nextTick,
onBeforeUnmount,
ref,
onMounted,
reactive,
computed,
watch,
} from 'vue'
import { onBeforeMount, nextTick, onBeforeUnmount, ref, onMounted, reactive, computed } from 'vue'
import ChatComponent from '@/views/chat/index.vue'
import LOGO from '@/assets/svg/logo-custom_small.svg'
import icon_new_chat_outlined from '@/assets/svg/icon_new_chat_outlined.svg'
Expand All @@ -50,7 +41,7 @@ import { useRoute } from 'vue-router'
import { useAssistantStore } from '@/stores/assistant'
import { setCurrentColor } from '@/utils/utils'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/i18n'
import { resolveWelcome } from '@/i18n/welcome'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()

Expand Down Expand Up @@ -147,27 +138,15 @@ onMounted(() => {
})
const customSet = reactive({
name: '',
welcome: t('embedded.i_am_sqlbot'),
welcome_desc: t('embedded.data_analysis_now'),
welcome: '',
welcome_desc: '',
theme: '#1CBA90',
header_font_color: '#1F2329',
}) as { [key: string]: any }

// 记录哪些字段被服务端 API 配置覆盖过,避免被 locale watcher 覆盖
const configuredKeys = new Set<string>()

// 监听 locale 变化,动态更新未被子定义覆盖的翻译字段
watch(
() => i18n.global.locale.value,
() => {
if (!configuredKeys.has('welcome')) {
customSet.welcome = t('embedded.i_am_sqlbot')
}
if (!configuredKeys.has('welcome_desc')) {
customSet.welcome_desc = t('embedded.data_analysis_now')
}
},
{ immediate: true }
const welcome = computed(() => resolveWelcome(customSet.welcome, 'embedded.i_am_sqlbot', t))
const welcomeDesc = computed(() =>
resolveWelcome(customSet.welcome_desc, 'embedded.data_analysis_now', t)
)

const logo = ref()
Expand Down Expand Up @@ -243,7 +222,6 @@ onBeforeMount(async () => {
![null, undefined].includes(rawData[key])
) {
customSet[key] = rawData[key]
configuredKeys.add(key)
}
}

Expand Down
31 changes: 9 additions & 22 deletions frontend/src/views/embedded/page.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<chat-component
v-if="!loading && tokenReady"
ref="chatRef"
:welcome="customSet.welcome"
:welcome-desc="customSet.welcome_desc"
:welcome="welcome"
:welcome-desc="welcomeDesc"
:logo-assistant="logo"
:page-embedded="true"
:app-name="customSet.name"
Expand All @@ -16,13 +16,13 @@
</template>
<script setup lang="ts">
import ChatComponent from '@/views/chat/index.vue'
import { nextTick, onBeforeMount, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { nextTick, onBeforeMount, onBeforeUnmount, reactive, ref, watch, computed } from 'vue'
import { useRoute } from 'vue-router'
// import { assistantApi } from '@/api/assistant'
import { useAssistantStore } from '@/stores/assistant'
import { useAppearanceStoreWithOut } from '@/stores/appearance'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/i18n'
import { resolveWelcome } from '@/i18n/welcome'
import { setCurrentColor } from '@/utils/utils'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
Expand All @@ -36,27 +36,15 @@ const assistantName = ref('')
const dynamicType = ref(0)
const customSet = reactive({
name: '',
welcome: t('embedded.i_am_sqlbot'),
welcome_desc: t('embedded.data_analysis_now'),
welcome: '',
welcome_desc: '',
theme: '#1CBA90',
header_font_color: '#1F2329',
}) as { [key: string]: any }

// 记录哪些字段被服务端 API 配置覆盖过,避免被 locale watcher 覆盖
const configuredKeys = new Set<string>()

// 监听 locale 变化,动态更新未被子定义覆盖的翻译字段
watch(
() => i18n.global.locale.value,
() => {
if (!configuredKeys.has('welcome')) {
customSet.welcome = t('embedded.i_am_sqlbot')
}
if (!configuredKeys.has('welcome_desc')) {
customSet.welcome_desc = t('embedded.data_analysis_now')
}
},
{ immediate: true }
const welcome = computed(() => resolveWelcome(customSet.welcome, 'embedded.i_am_sqlbot', t))
const welcomeDesc = computed(() =>
resolveWelcome(customSet.welcome_desc, 'embedded.data_analysis_now', t)
)

const logo = ref()
Expand Down Expand Up @@ -244,7 +232,6 @@ onBeforeMount(async () => {
![null, undefined].includes(rawData[key])
) {
customSet[key] = rawData[key]
configuredKeys.add(key)
}
}

Expand Down
6 changes: 5 additions & 1 deletion frontend/src/views/login/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
}}</span>
</div>
<div v-if="appearanceStore.getShowSlogan" class="welcome">
{{ appearanceStore.slogan ?? $t('common.intelligent_questioning_platform') }}
{{ loginSlogan }}
</div>
<div v-else class="welcome" style="height: 0"></div>
<div class="login-form">
Expand Down Expand Up @@ -88,6 +88,7 @@ import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
import { resolveWelcome } from '@/i18n/welcome'
import custom_small from '@/assets/svg/logo-custom_small.svg'
import LOGO_fold from '@/assets/LOGO-fold.svg'
import login_image from '@/assets/embedded/login_image.png'
Expand All @@ -103,6 +104,9 @@ const router = useRouter()
const userStore = useUserStore()
const appearanceStore = useAppearanceStoreWithOut()
const { t } = useI18n()
const loginSlogan = computed(() =>
resolveWelcome(appearanceStore.slogan, 'common.intelligent_questioning_platform', t)
)
const loginForm = ref({
username: '',
password: '',
Expand Down
Loading
Loading