Skip to content
Open
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
2 changes: 2 additions & 0 deletions frontend/src/api/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export interface IMChannel {
mode: 'webhook' | 'websocket' | 'longpoll';
output_mode: 'stream' | 'full';
locale?: '' | 'zh-CN' | 'zh-TW' | 'en-US' | 'ko-KR' | 'ja-JP' | 'ru-RU';
language_mode?: 'fixed' | 'follow_user';
session_mode?: 'user' | 'thread';
knowledge_base_id?: string;
credentials: Record<string, any>;
Expand All @@ -380,6 +381,7 @@ export interface IMChannelOverview {
mode: IMChannel['mode'];
output_mode: IMChannel['output_mode'];
locale?: IMChannel['locale'];
language_mode?: IMChannel['language_mode'];
session_mode?: IMChannel['session_mode'];
bot_identity: string;
created_at: string;
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/components/IMChannelPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,9 @@

<div class="form-item">
<label class="form-label">{{ $t('agentEditor.im.replyLanguage') }}</label>
<t-select v-model="formData.locale" :options="localeOptions" />
<p class="form-desc">{{ $t('agentEditor.im.replyLanguageHint') }}</p>
<t-select v-model="formData.language_mode" :options="languageModeOptions" />
<t-select v-if="formData.language_mode === 'fixed'" v-model="formData.locale" :options="localeOptions" />
<p class="form-desc">{{ formData.language_mode === 'follow_user' ? $t('agentEditor.im.replyLanguageFollowHint') : $t('agentEditor.im.replyLanguageHint') }}</p>
</div>
</section>

Expand Down Expand Up @@ -691,6 +692,11 @@ const localeOptions = computed(() => ([
{ value: 'ru-RU' as IMLocale, label: 'Русский' },
]));

const languageModeOptions = computed(() => ([
{ value: 'fixed', label: t('agentEditor.im.replyLanguageFixed') },
{ value: 'follow_user', label: t('agentEditor.im.replyLanguageFollowUser') },
]));

// Feishu and Lark are the same product on separate clouds, so each has its own
// open platform console. Bots must be created on the one matching the channel.
const openPlatformConsole = computed(() =>
Expand Down Expand Up @@ -748,6 +754,7 @@ const formData = ref({
mode: 'websocket' as 'webhook' | 'websocket' | 'longpoll',
output_mode: 'stream' as 'stream' | 'full',
locale: '' as IMLocale,
language_mode: 'fixed' as 'fixed' | 'follow_user',
session_mode: 'user' as 'user' | 'thread',
knowledge_base_id: '',
credentials: defaultCredentials(),
Expand Down Expand Up @@ -1004,6 +1011,7 @@ async function editChannel(channel: IMChannel | IMChannelOverview) {
mode: fullChannel.mode,
output_mode: fullChannel.output_mode,
locale: fullChannel.locale || '',
language_mode: fullChannel.language_mode || 'fixed',
session_mode: fullChannel.session_mode || 'user',
knowledge_base_id: fullChannel.knowledge_base_id || '',
credentials: { ...fullChannel.credentials },
Expand All @@ -1029,6 +1037,7 @@ function resetForm() {
mode: 'websocket',
output_mode: 'stream',
locale: '',
language_mode: 'fixed',
session_mode: 'user',
knowledge_base_id: '',
credentials: defaultCredentials(),
Expand Down Expand Up @@ -1059,6 +1068,7 @@ async function handleSave() {
mode: formData.value.mode,
output_mode: formData.value.output_mode,
locale: formData.value.locale,
language_mode: formData.value.language_mode,
session_mode: formData.value.session_mode,
knowledge_base_id: normalizeOptionalString(formData.value.knowledge_base_id),
credentials: formData.value.credentials,
Expand All @@ -1078,6 +1088,7 @@ async function handleSave() {
mode: formData.value.mode,
output_mode: formData.value.output_mode,
locale: formData.value.locale,
language_mode: formData.value.language_mode,
session_mode: formData.value.session_mode,
knowledge_base_id: normalizeOptionalString(formData.value.knowledge_base_id),
credentials: formData.value.credentials,
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8009,6 +8009,9 @@ export default {
sessionModeThread: 'Per Thread',
sessionModeHint: 'User mode: each person has their own conversation. Use /clear to start fresh. Thread mode: each message thread is a separate conversation. Multiple people can collaborate in the same thread.',
replyLanguage: 'Reply Language',
replyLanguageFixed: 'Fixed language',
replyLanguageFollowUser: 'Follow the user',
replyLanguageFollowHint: 'Detect the language of each message. Short or unclear messages use the last detected language in this conversation, then the deployment default.',
replyLanguageDefault: 'Use system default',
replyLanguageHint: 'Fix the agent reply language for this channel. Leave unset to use the deployment default.',
wechatScanBind: 'Scan to bind WeChat',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8009,6 +8009,9 @@ export default {
sessionModeThread: 'スレッド単位',
sessionModeHint: 'ユーザモード: 各ユーザが個別の会話を持ちます。/clearで新しく開始できます。スレッドモード: メッセージスレッドごとに個別の会話になります。同じスレッドで複数人が共同作業できます。',
replyLanguage: '返信言語',
replyLanguageFixed: '固定言語',
replyLanguageFollowUser: 'ユーザーの言語に合わせる',
replyLanguageFollowHint: '各メッセージの言語を検出します。短文や判定不能な場合は、この会話で最後に検出された言語、次にデプロイのデフォルト言語を使用します。',
replyLanguageDefault: 'システムのデフォルトを使用',
replyLanguageHint: 'このチャネルでエージェントが返信する言語を固定します。未設定の場合はデプロイのデフォルト言語を使用します。',
wechatScanBind: 'スキャンしてWeChatを連携',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,9 @@ export default {
sessionModeThread: '스레드별',
sessionModeHint: '사용자 모드: 각 사용자별 독립 대화. /clear로 새 대화 시작. 스레드 모드: 메시지 스레드별 독립 대화. 같은 스레드에서 여러 명이 함께 대화 가능.',
replyLanguage: '응답 언어',
replyLanguageFixed: '고정 언어',
replyLanguageFollowUser: '사용자 언어 따르기',
replyLanguageFollowHint: '메시지마다 언어를 감지합니다. 짧거나 불분명한 메시지는 이 대화에서 마지막으로 감지한 언어를 사용하고, 없으면 배포 기본 언어를 사용합니다.',
replyLanguageDefault: '시스템 기본값 사용',
replyLanguageHint: '이 채널의 에이전트 응답 언어를 고정합니다. 설정하지 않으면 배포 기본 언어를 사용합니다.',
wechatScanBind: 'QR 코드를 스캔하여 WeChat 연결',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/ru-RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,9 @@ export default {
sessionModeThread: 'По потоку',
sessionModeHint: 'Режим пользователя: отдельный диалог для каждого пользователя. /clear для нового диалога. Режим потока: отдельный диалог для каждого потока. Несколько человек могут общаться в одном потоке.',
replyLanguage: 'Язык ответов',
replyLanguageFixed: 'Фиксированный язык',
replyLanguageFollowUser: 'Следовать языку пользователя',
replyLanguageFollowHint: 'Язык определяется для каждого сообщения. Для коротких или неясных сообщений используется последний определённый язык беседы, затем язык развёртывания по умолчанию.',
replyLanguageDefault: 'Использовать системный язык',
replyLanguageHint: 'Фиксирует язык ответов агента для этого канала. Если не задан, используется язык развёртывания по умолчанию.',
wechatScanBind: 'Сканировать QR-код для привязки WeChat',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,9 @@ export default {
sessionModeThread: '按话题',
sessionModeHint: '用户模式:每个用户独立对话,使用 /clear 开始新对话。话题模式:每个消息话题独立对话,同一话题中多人可协作。',
replyLanguage: '回复语言',
replyLanguageFixed: '固定语言',
replyLanguageFollowUser: '跟随用户语言',
replyLanguageFollowHint: '按消息检测回复语言。短消息或无法判断时沿用本会话上次识别的语言,再回退到部署默认语言。',
replyLanguageDefault: '跟随系统默认语言',
replyLanguageHint: '固定该渠道的智能体回复语言;留空时使用部署的默认语言。',
wechatScanBind: '扫码绑定微信',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1694,6 +1694,9 @@ export default {
sessionModeThread: '按話題',
sessionModeHint: '使用者模式:每個使用者獨立對話,使用 /clear 開始新對話。話題模式:每個訊息話題獨立對話,同一話題中多人可協作。',
replyLanguage: '回覆語言',
replyLanguageFixed: '固定語言',
replyLanguageFollowUser: '跟隨使用者語言',
replyLanguageFollowHint: '按訊息偵測回覆語言。短訊息或無法判斷時沿用本會話上次識別的語言,再回退到部署預設語言。',
replyLanguageDefault: '跟隨系統預設語言',
replyLanguageHint: '固定該通路的智慧代理回覆語言;留空時使用部署的預設語言。',
wechatScanBind: '掃碼綁定微信',
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.0
require (
codeberg.org/readeck/go-readability/v2 v2.1.2
connectrpc.com/connect v1.19.1
github.com/abadojack/whatlanggo v1.0.1
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.1
github.com/PuerkitoBio/goquery v1.12.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,8 @@ github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1o
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw=
Expand Down
90 changes: 90 additions & 0 deletions internal/database/im_reply_language_migration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package database

import (
"os"
"path/filepath"
"testing"

"github.com/golang-migrate/migrate/v4"
sqlite3migrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/stretchr/testify/require"
)

func TestSQLiteIMReplyLanguageUpgradeAndRollback(t *testing.T) {
repoRoot := sqliteRepoRoot(t)
chdirAndRestore(t, copySQLiteMigrationsThrough(t, repoRoot, 41))
dbPath := filepath.Join(t.TempDir(), "im-reply-language.db")
opts := MigrationOptions{SQLiteDBPath: dbPath}
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", opts))
db := openSQLiteDB(t, dbPath)
_, err := db.Exec(`INSERT INTO sessions (id, tenant_id) VALUES ('session', 1)`)
require.NoError(t, err)
_, err = db.Exec(`INSERT INTO im_channels (id, tenant_id, agent_id, platform, name, locale)
VALUES ('channel', 1, 'agent', 'feishu', 'Existing channel', 'zh-CN')`)
require.NoError(t, err)
_, err = db.Exec(`INSERT INTO im_channel_sessions (id, platform, user_id, session_id, tenant_id, im_channel_id)
VALUES ('mapping', 'feishu', 'user', 'session', 1, 'channel')`)
require.NoError(t, err)

upgradeRoot := copySQLiteMigrationsThrough(t, repoRoot, 52)
downFile := filepath.Join("migrations", "sqlite", "000052_im_follow_user_language.down.sql")
downSQL, err := os.ReadFile(filepath.Join(repoRoot, downFile))
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(upgradeRoot, downFile), downSQL, 0o600))
chdirAndRestore(t, upgradeRoot)
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", opts))
// Repeated startup must not replay the ALTER TABLE statements.
require.NoError(t, RunMigrationsWithOptions("sqlite3://unused", opts))
version, dirty := sqliteMigrationState(t, db)
require.Equal(t, 52, version)
require.False(t, dirty)
var name, locale, mode, language, sessionID string
require.NoError(t, db.QueryRow(`SELECT name, locale, language_mode FROM im_channels
WHERE id = 'channel'`).Scan(&name, &locale, &mode))
require.Equal(t, "Existing channel", name)
require.Equal(t, "zh-CN", locale)
require.Equal(t, "fixed", mode)
require.NoError(t, db.QueryRow(`SELECT session_id, last_detected_language FROM im_channel_sessions
WHERE id = 'mapping'`).Scan(&sessionID, &language))
require.Equal(t, "session", sessionID)
require.Empty(t, language)
_, err = db.Exec(`UPDATE im_channels SET language_mode = 'follow_user' WHERE id = 'channel'`)
require.NoError(t, err)
require.NoError(t, db.QueryRow(`SELECT language_mode FROM im_channels
WHERE id = 'channel'`).Scan(&mode))
require.Equal(t, "follow_user", mode)
_, err = db.Exec(`UPDATE im_channel_sessions SET last_detected_language = 'vi' WHERE id = 'mapping'`)
require.NoError(t, err)
require.NoError(t, db.QueryRow(`SELECT last_detected_language FROM im_channel_sessions
WHERE id = 'mapping'`).Scan(&language))
require.Equal(t, "vi", language)

driver, err := sqlite3migrate.WithInstance(db, &sqlite3migrate.Config{})
require.NoError(t, err)
m, err := migrate.NewWithDatabaseInstance("file://migrations/sqlite", "sqlite3", driver)
require.NoError(t, err)
t.Cleanup(func() { _, _ = m.Close() })
require.NoError(t, m.Steps(-1))
version, dirty = sqliteMigrationState(t, db)
require.Equal(t, 51, version)
require.False(t, dirty)
require.False(t, sqliteColumnExists(t, db, "im_channels", "language_mode"))
require.False(t, sqliteColumnExists(t, db, "im_channel_sessions", "last_detected_language"))
require.NoError(t, db.QueryRow(`SELECT name, locale FROM im_channels
WHERE id = 'channel'`).Scan(&name, &locale))
require.Equal(t, "Existing channel", name)
require.Equal(t, "zh-CN", locale)
require.NoError(t, db.QueryRow(`SELECT session_id FROM im_channel_sessions
WHERE id = 'mapping'`).Scan(&sessionID))
require.Equal(t, "session", sessionID)
require.NoError(t, m.Steps(1))
version, dirty = sqliteMigrationState(t, db)
require.Equal(t, 52, version)
require.False(t, dirty)
require.NoError(t, db.QueryRow(`SELECT language_mode FROM im_channels
WHERE id = 'channel'`).Scan(&mode))
require.Equal(t, "fixed", mode)
require.NoError(t, db.QueryRow(`SELECT last_detected_language FROM im_channel_sessions
WHERE id = 'mapping'`).Scan(&language))
require.Empty(t, language)
}
5 changes: 3 additions & 2 deletions internal/database/migration_sqlite_versioned_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ var versionedSQLiteColumns = map[string][]string{
"session_sandbox_assignments": {"released_at", "cleanup_due_at"}, // 000047
"tenant_invitations": {"token", "accepted_count"}, // 000054
"embed_channels": {"allow_memory"}, // 000060
"im_channels": {"locale"}, // 000030
"im_channels": {"locale", "language_mode"}, // 000030/052
"im_channel_sessions": {"last_detected_language"}, // 000052
"chunks": {"source_locators"}, // 000033
"mcp_oauth_tokens": {"principal_type", "principal_id"}, // 000064
"mcp_tool_approvals": {"enabled", "risk", "observed_risk"}, // 000091, 000126
Expand All @@ -88,7 +89,7 @@ var versionedSQLiteColumns = map[string][]string{
}, // 000028
}

const expectedSQLiteMigrationVersion = 51
const expectedSQLiteMigrationVersion = 52

func TestSQLiteMigrationsCreateVersionedSchema(t *testing.T) {
repoRoot := sqliteRepoRoot(t)
Expand Down
36 changes: 26 additions & 10 deletions internal/handler/im.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
Mode string `json:"mode"`
OutputMode string `json:"output_mode"`
Locale string `json:"locale"`
LanguageMode string `json:"language_mode"`
SessionMode string `json:"session_mode"`
KnowledgeBaseID string `json:"knowledge_base_id"`
Credentials types.JSON `json:"credentials"`
Expand All @@ -85,18 +86,24 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
languageMode, err := im.NormalizeLanguageMode(req.LanguageMode)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

channel := &im.IMChannel{
TenantID: tenantID,
AgentID: agentID,
Platform: req.Platform,
Name: req.Name,
Mode: req.Mode,
OutputMode: req.OutputMode,
Locale: locale,
SessionMode: req.SessionMode,
Credentials: req.Credentials,
Enabled: true,
TenantID: tenantID,
AgentID: agentID,
Platform: req.Platform,
Name: req.Name,
Mode: req.Mode,
OutputMode: req.OutputMode,
Locale: locale,
LanguageMode: languageMode,
SessionMode: req.SessionMode,
Credentials: req.Credentials,
Enabled: true,
}
// The route guard passes an agent it cannot find in this workspace; both
// the agent and the file-saving KB must belong to the channel's workspace.
Expand Down Expand Up @@ -226,6 +233,7 @@ func (h *IMHandler) UpdateIMChannel(c *gin.Context) {
Mode *string `json:"mode"`
OutputMode *string `json:"output_mode"`
Locale *string `json:"locale"`
LanguageMode *string `json:"language_mode"`
SessionMode *string `json:"session_mode"`
KnowledgeBaseID *string `json:"knowledge_base_id"`
Credentials types.JSON `json:"credentials"`
Expand Down Expand Up @@ -254,6 +262,14 @@ func (h *IMHandler) UpdateIMChannel(c *gin.Context) {
}
channel.Locale = locale
}
if req.LanguageMode != nil {
languageMode, err := im.NormalizeLanguageMode(*req.LanguageMode)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
channel.LanguageMode = languageMode
}
if req.SessionMode != nil {
channel.SessionMode = *req.SessionMode
}
Expand Down
20 changes: 20 additions & 0 deletions internal/handler/im_locale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,23 @@ func TestCreateIMChannelRejectsUnsupportedLocale(t *testing.T) {
t.Fatalf("body = %s, want locale validation error", recorder.Body.String())
}
}

func TestCreateIMChannelRejectsInvalidLanguageMode(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/agents/:id/im-channels", NewIMHandler(nil).CreateIMChannel)

req := httptest.NewRequest(
http.MethodPost,
"/agents/agent-1/im-channels",
strings.NewReader(`{"platform":"feishu","language_mode":"auto"}`),
)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), types.TenantIDContextKey, uint64(42)))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "language_mode must be") {
t.Fatalf("status = %d, body = %s; want language mode validation error", recorder.Code, recorder.Body.String())
}
}
Loading
Loading