Skip to content

Commit 68d48c1

Browse files
authored
添加回复字数限制设置功能 (#638)
* 添加回复字数限制设置功能 设置存储白名单、优先从用户设置读取 用户修改设置后立即生效,无需重启会话 * 修复Ruff警告;修复异常处理缺少初始化;修复滑条布局错误;修复bool被误判为int;统一前后端默认值350;添加功能范围说明;固定设置面板宽度
1 parent 7af1bfd commit 68d48c1

11 files changed

Lines changed: 280 additions & 13 deletions

File tree

main_logic/core.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from main_logic.omni_realtime_client import OmniRealtimeClient
1919
from main_logic.omni_offline_client import OmniOfflineClient
2020
from main_logic.tts_client import get_tts_worker
21+
from utils.preferences import load_global_conversation_settings
2122
from config import MEMORY_SERVER_PORT, TOOL_SERVER_PORT
2223
from config.prompts_sys import (
2324
_loc,
@@ -280,12 +281,20 @@ def __init__(self, sync_message_queue, lanlan_name, lanlan_prompt):
280281

281282
def _get_text_guard_max_length(self) -> int:
282283
try:
283-
value = int(self._config_manager.get_core_config().get('TEXT_GUARD_MAX_LENGTH', 300))
284-
if value <= 0:
284+
# 优先从对话设置中读取,如果不存在则从核心配置读取
285+
conversation_settings = load_global_conversation_settings()
286+
if 'textGuardMaxLength' in conversation_settings:
287+
value = int(conversation_settings['textGuardMaxLength'])
288+
else:
289+
value = int(self._config_manager.get_core_config().get('TEXT_GUARD_MAX_LENGTH', 350))
290+
# 0 表示无限制,返回一个很大的数
291+
if value == 0:
292+
return 999999
293+
if value < 0:
285294
raise ValueError
286295
return value
287296
except Exception:
288-
return 300
297+
return 350
289298

290299
async def _clear_tts_pipeline(self):
291300
"""清空 TTS 请求/响应队列和待处理缓存,停止当前合成。"""
@@ -2106,6 +2115,9 @@ async def trigger_agent_callbacks(self) -> None:
21062115
async with self.lock:
21072116
self.current_speech_id = str(uuid4())
21082117
logger.debug("[%s] trigger_agent_callbacks: text session ready, calling stream_proactive", self.lanlan_name)
2118+
# 更新字数限制(可能用户在对话期间修改了设置)
2119+
if hasattr(self.session, 'update_max_response_length'):
2120+
self.session.update_max_response_length(self._get_text_guard_max_length())
21092121
self.pending_agent_callbacks.clear()
21102122
delivered = await self.session.stream_proactive(instruction)
21112123
logger.debug("[%s] trigger_agent_callbacks: text session stream_proactive delivered=%s", self.lanlan_name, delivered)
@@ -2125,6 +2137,9 @@ async def trigger_agent_callbacks(self) -> None:
21252137
async with self._proactive_write_lock:
21262138
async with self.lock:
21272139
self.current_speech_id = str(uuid4())
2140+
# 更新字数限制(可能用户在对话期间修改了设置)
2141+
if hasattr(self.session, 'update_max_response_length'):
2142+
self.session.update_max_response_length(self._get_text_guard_max_length())
21282143
self.pending_agent_callbacks.clear()
21292144
delivered = await self.session.stream_proactive(instruction)
21302145
if delivered:
@@ -2546,6 +2561,10 @@ async def _process_stream_data_internal(self, message: dict):
25462561

25472562
# 文本模式:直接发送文本
25482563
if isinstance(data, str):
2564+
# 更新字数限制(可能用户在对话期间修改了设置)
2565+
if hasattr(self.session, 'update_max_response_length'):
2566+
self.session.update_max_response_length(self._get_text_guard_max_length())
2567+
25492568
# 先打断当前正在播放的语音(旧speech_id),避免误打断新回复
25502569
async with self.lock:
25512570
interrupted_speech_id = self.current_speech_id

main_logic/omni_offline_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ def __init__(
119119

120120
# 质量守卫回调:由 core.py 设置,用于通知前端清理气泡
121121

122+
def update_max_response_length(self, max_length: int) -> None:
123+
"""更新回复字数限制(用户可能在对话期间修改设置)"""
124+
if isinstance(max_length, int) and max_length >= 0:
125+
self.max_response_length = max_length if max_length > 0 else 999999
126+
logger.debug(f"OmniOfflineClient: 字数限制已更新为 {max_length}")
127+
122128
def _match_name_prefix(self, text: str, name: str) -> int:
123129
"""Check if text starts with a name prefix like 'Name | ' or 'Name |'.
124130
Returns the length of the matched prefix, or 0 if no match.

static/app-settings.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
focusModeEnabled: S.focusModeEnabled,
3636
proactiveChatInterval: S.proactiveChatInterval,
3737
proactiveVisionInterval: S.proactiveVisionInterval,
38-
subtitleEnabled: S.subtitleEnabled
38+
subtitleEnabled: S.subtitleEnabled,
39+
textGuardMaxLength: S.textGuardMaxLength
3940
};
4041
// 只有在 S 上存在 userLanguage 属性时才包含(含 null,支持显式清除语义)
4142
if ('userLanguage' in S) {
@@ -170,6 +171,9 @@
170171
const currentMemeChat = typeof window.proactiveMemeEnabled !== 'undefined'
171172
? window.proactiveMemeEnabled
172173
: S.proactiveMemeEnabled;
174+
const currentTextGuardMaxLength = typeof window.textGuardMaxLength !== 'undefined'
175+
? window.textGuardMaxLength
176+
: S.textGuardMaxLength;
173177
const currentRenderQuality = typeof window.renderQuality !== 'undefined'
174178
? window.renderQuality
175179
: S.renderQuality;
@@ -203,6 +207,7 @@
203207
focusModeEnabled: currentFocus,
204208
proactiveChatInterval: currentProactiveChatInterval,
205209
proactiveVisionInterval: currentProactiveVisionInterval,
210+
textGuardMaxLength: currentTextGuardMaxLength,
206211
renderQuality: currentRenderQuality,
207212
targetFrameRate: currentTargetFrameRate,
208213
mouseTrackingEnabled: currentMouseTracking,
@@ -233,11 +238,15 @@
233238
S.focusModeEnabled = currentFocus;
234239
S.proactiveChatInterval = currentProactiveChatInterval;
235240
S.proactiveVisionInterval = currentProactiveVisionInterval;
241+
S.textGuardMaxLength = currentTextGuardMaxLength;
236242
S.renderQuality = currentRenderQuality;
237243
S.targetFrameRate = currentTargetFrameRate;
238244
// 同步字幕设置到共享状态
239245
S.subtitleEnabled = currentSubtitleEnabled;
240246
S.userLanguage = currentUserLanguage;
247+
248+
// 同步到服务器(异步,不阻塞)
249+
syncSettingsToServer();
241250
}
242251

243252
// ======================== loadSettings ========================
@@ -306,6 +315,9 @@
306315
S.focusModeEnabled = settings.focusModeEnabled ?? false;
307316
S.proactiveChatInterval = settings.proactiveChatInterval ?? C.DEFAULT_PROACTIVE_CHAT_INTERVAL;
308317
S.proactiveVisionInterval = settings.proactiveVisionInterval ?? C.DEFAULT_PROACTIVE_VISION_INTERVAL;
318+
// 字数限制设置(默认350字)
319+
S.textGuardMaxLength = settings.textGuardMaxLength ?? 350;
320+
window.textGuardMaxLength = S.textGuardMaxLength;
309321
// 画质设置
310322
S.renderQuality = settings.renderQuality ?? 'medium';
311323
window.cursorFollowPerformanceLevel = U.mapRenderQualityToFollowPerf(S.renderQuality);
@@ -367,6 +379,9 @@
367379
// 首次启动默认开启音乐/meme搭话
368380
S.proactiveMusicEnabled = true;
369381
S.proactiveMemeEnabled = true;
382+
// 首次启动默认字数限制为350
383+
S.textGuardMaxLength = 350;
384+
window.textGuardMaxLength = 350;
370385

371386
console.log('未找到保存的设置,使用默认值');
372387
window.cursorFollowPerformanceLevel = U.mapRenderQualityToFollowPerf(S.renderQuality);
@@ -381,6 +396,8 @@
381396
} catch (error) {
382397
console.error('加载本地设置失败:', error);
383398
// 出错时也要确保全局变量被初始化
399+
S.textGuardMaxLength = 350;
400+
window.textGuardMaxLength = 350;
384401
window.cursorFollowPerformanceLevel = U.mapRenderQualityToFollowPerf(S.renderQuality);
385402
window.mouseTrackingEnabled = true;
386403
window.live2dFullscreenTrackingEnabled = false;
@@ -428,6 +445,7 @@
428445
window.focusModeEnabled = S.focusModeEnabled;
429446
window.proactiveChatInterval = S.proactiveChatInterval;
430447
window.proactiveVisionInterval = S.proactiveVisionInterval;
448+
window.textGuardMaxLength = S.textGuardMaxLength;
431449
// 同步回 localStorage
432450
saveSettings();
433451
// 重新初始化主动搭话调度器(使用最新标志)

static/avatar-ui-popup.js

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,8 @@ function createChatSettingsSidePanel(manager, prefix, popup) {
430430
container.style.flexDirection = 'column';
431431
container.style.alignItems = 'stretch';
432432
container.style.gap = '2px';
433-
container.style.minWidth = '160px';
433+
container.style.width = '200px';
434+
container.style.minWidth = '0';
434435
container.style.padding = '4px 4px';
435436

436437
const chatToggles = [
@@ -443,10 +444,187 @@ function createChatSettingsSidePanel(manager, prefix, popup) {
443444
container.appendChild(toggleItem);
444445
});
445446

447+
// 字数限制滑动条
448+
const textGuardContainer = manager._createTextGuardSlider();
449+
container.appendChild(textGuardContainer);
450+
446451
document.body.appendChild(container);
447452
return container;
448453
}
449454

455+
/**
456+
* 创建字数限制滑动条
457+
*/
458+
function createTextGuardSlider(manager, prefix) {
459+
const container = document.createElement('div');
460+
Object.assign(container.style, {
461+
display: 'flex',
462+
flexDirection: 'column',
463+
gap: '4px',
464+
padding: '4px 0'
465+
});
466+
467+
// 标签和数值行
468+
const labelRow = document.createElement('div');
469+
Object.assign(labelRow.style, {
470+
display: 'flex',
471+
justifyContent: 'space-between',
472+
alignItems: 'center',
473+
gap: '8px'
474+
});
475+
476+
const label = document.createElement('span');
477+
label.textContent = window.t ? window.t('settings.toggles.textGuardMaxLength') : '回复字数限制';
478+
label.setAttribute('data-i18n', 'settings.toggles.textGuardMaxLength');
479+
Object.assign(label.style, {
480+
fontSize: '12px',
481+
color: 'var(--neko-popup-text, #333)',
482+
flexShrink: '0'
483+
});
484+
485+
const valueDisplay = document.createElement('span');
486+
Object.assign(valueDisplay.style, {
487+
fontSize: '12px',
488+
color: 'var(--neko-popup-active, #2a7bc4)',
489+
fontWeight: '500',
490+
minWidth: '60px',
491+
textAlign: 'right'
492+
});
493+
494+
labelRow.appendChild(label);
495+
labelRow.appendChild(valueDisplay);
496+
497+
// 滑动条行
498+
const sliderRow = document.createElement('div');
499+
Object.assign(sliderRow.style, {
500+
display: 'flex',
501+
alignItems: 'center',
502+
gap: '8px',
503+
width: '100%'
504+
});
505+
506+
const slider = document.createElement('input');
507+
slider.type = 'range';
508+
// 滑动条位置:0-10 对应 50-1500(每档150字),11 对应无限制
509+
// 默认值 350字 = (350-50)/150 = 2
510+
slider.min = '0';
511+
slider.max = '11';
512+
slider.step = '1';
513+
514+
// 当前值转换:数值 -> 滑动条位置
515+
const currentValue = typeof window.textGuardMaxLength !== 'undefined' ? window.textGuardMaxLength : 350;
516+
let currentPosition;
517+
if (currentValue === 0 || currentValue === null || currentValue === undefined) {
518+
currentPosition = 11; // 无限制
519+
} else {
520+
// 找到最接近的档位:50 + position * 150
521+
currentPosition = Math.min(10, Math.max(0, Math.round((currentValue - 50) / 150)));
522+
}
523+
slider.value = currentPosition;
524+
525+
Object.assign(slider.style, {
526+
flex: '1',
527+
height: '4px',
528+
cursor: 'pointer',
529+
accentColor: 'var(--neko-popup-accent, #44b7fe)'
530+
});
531+
532+
// 更新显示文本
533+
const updateDisplay = (position) => {
534+
if (parseInt(position) === 11) {
535+
const unlimitedText = (typeof window.t === 'function') ? window.t('settings.toggles.unlimited') : '无限制';
536+
valueDisplay.textContent = unlimitedText;
537+
valueDisplay.setAttribute('data-i18n', 'settings.toggles.unlimited');
538+
} else {
539+
const value = 50 + parseInt(position) * 150;
540+
const unit = (typeof window.t === 'function') ? window.t('settings.toggles.characters') : '字';
541+
valueDisplay.textContent = `${value}${unit}`;
542+
valueDisplay.removeAttribute('data-i18n');
543+
}
544+
};
545+
546+
updateDisplay(currentPosition);
547+
548+
// 警告提示
549+
const warningRow = document.createElement('div');
550+
Object.assign(warningRow.style, {
551+
fontSize: '11px',
552+
color: '#ff6b6b',
553+
lineHeight: '1.4',
554+
minHeight: '16px',
555+
opacity: '0',
556+
transition: 'opacity 0.2s ease'
557+
});
558+
559+
const updateWarning = (position) => {
560+
const pos = parseInt(position);
561+
const value = 50 + pos * 150;
562+
if (pos === 11) {
563+
// 无限制
564+
const warningText = (typeof window.t === 'function')
565+
? window.t('settings.toggles.textGuardUnlimitedWarning')
566+
: '无限制可能导致模型生成过长回复,消耗较多Token';
567+
warningRow.textContent = warningText;
568+
warningRow.style.opacity = '1';
569+
} else if (value > 500) {
570+
// 超过500字显示提示
571+
const warningText = (typeof window.t === 'function')
572+
? window.t('settings.toggles.textGuardHighWarning')
573+
: '设置过大可能导致回复过长,建议保持在500字以内';
574+
warningRow.textContent = warningText;
575+
warningRow.style.opacity = '1';
576+
} else {
577+
warningRow.style.opacity = '0';
578+
}
579+
};
580+
581+
updateWarning(currentPosition);
582+
583+
slider.addEventListener('input', () => {
584+
const position = parseInt(slider.value);
585+
updateDisplay(position);
586+
updateWarning(position);
587+
});
588+
589+
slider.addEventListener('change', () => {
590+
const position = parseInt(slider.value);
591+
let value;
592+
if (position === 11) {
593+
value = 0; // 0 表示无限制
594+
} else {
595+
value = 50 + position * 150;
596+
}
597+
window.textGuardMaxLength = value;
598+
if (typeof window.saveNEKOSettings === 'function') window.saveNEKOSettings();
599+
console.log(`[TextGuard] 回复字数限制已设置为 ${value === 0 ? '无限制' : value + '字'}`);
600+
});
601+
602+
slider.addEventListener('click', (e) => e.stopPropagation());
603+
slider.addEventListener('mousedown', (e) => e.stopPropagation());
604+
605+
sliderRow.appendChild(slider);
606+
607+
// 底部提示(仅对文本回复有效)
608+
const noteRow = document.createElement('div');
609+
Object.assign(noteRow.style, {
610+
fontSize: '10px',
611+
color: '#888',
612+
lineHeight: '1.4',
613+
marginTop: '4px'
614+
});
615+
const noteText = (typeof window.t === 'function')
616+
? window.t('settings.toggles.textGuardNote')
617+
: '仅对文本回复有效,不影响语音对话';
618+
noteRow.textContent = noteText;
619+
620+
container.appendChild(labelRow);
621+
container.appendChild(sliderRow);
622+
container.appendChild(warningRow);
623+
container.appendChild(noteRow);
624+
625+
return container;
626+
}
627+
450628
/**
451629
* 创建角色设置侧边面板
452630
*/
@@ -455,7 +633,8 @@ function createCharacterSettingsSidePanel(manager, prefix) {
455633
container.style.flexDirection = 'column';
456634
container.style.alignItems = 'stretch';
457635
container.style.gap = '2px';
458-
container.style.minWidth = '140px';
636+
container.style.width = '160px';
637+
container.style.minWidth = '0';
459638
container.style.padding = '4px 8px';
460639

461640
const items = manager._characterMenuItems || [];
@@ -1727,6 +1906,10 @@ const AvatarPopupMixin = {
17271906
return createAnimationSettingsSidePanel(this, prefix);
17281907
};
17291908

1909+
ManagerProto._createTextGuardSlider = function () {
1910+
return createTextGuardSlider(this, prefix);
1911+
};
1912+
17301913
ManagerProto._createSidePanelContainer = function (panelOptions = {}) {
17311914
return createSidePanelContainer(this, prefix, options.sidePanelContainerLayout || panelOptions);
17321915
};

static/locales/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1551,7 +1551,13 @@
15511551
"frameRateUnlimited": "Unlimited(VSync)",
15521552
"mouseTracking": "Mouse Tracking",
15531553
"fullscreenTracking": "Fullscreen Tracking",
1554-
"localTracking": "Local Tracking"
1554+
"localTracking": "Local Tracking",
1555+
"textGuardMaxLength": "Response Length Limit",
1556+
"unlimited": "Unlimited",
1557+
"characters": " chars",
1558+
"textGuardUnlimitedWarning": "May cause serious issues with the cat girl, not recommended unless necessary",
1559+
"textGuardHighWarning": "Setting too high may cause memory and language confusion, recommended within 500 chars",
1560+
"textGuardNote": "Applies to text replies only; does not affect voice sessions"
15551561
}
15561562
},
15571563
"subtitle": {

0 commit comments

Comments
 (0)