diff --git a/main/manager-api/src/main/resources/db/changelog/202504112044.sql b/main/manager-api/src/main/resources/db/changelog/202504112044.sql index 9fd2257d..df00d3b8 100644 --- a/main/manager-api/src/main/resources/db/changelog/202504112044.sql +++ b/main/manager-api/src/main/resources/db/changelog/202504112044.sql @@ -32,7 +32,7 @@ INSERT INTO `ai_model_config` VALUES ('TTS_EdgeTTS', 'TTS', 'EdgeTTS', 'Edge语 INSERT INTO `ai_model_config` VALUES ('TTS_DoubaoTTS', 'TTS', 'DoubaoTTS', '豆包语音合成', 0, 1, '{\"type\": \"doubao\", \"api_url\": \"https://openspeech.bytedance.com/api/v1/tts\", \"voice\": \"BV001_streaming\", \"output_dir\": \"tmp/\", \"authorization\": \"Bearer;\", \"appid\": \"\", \"access_token\": \"\", \"cluster\": \"volcano_tts\"}', NULL, NULL, 2, NULL, NULL, NULL, NULL); INSERT INTO `ai_model_config` VALUES ('TTS_CosyVoiceSiliconflow', 'TTS', 'CosyVoiceSiliconflow', '硅基流动语音合成', 0, 1, '{\"type\": \"siliconflow\", \"model\": \"FunAudioLLM/CosyVoice2-0.5B\", \"voice\": \"FunAudioLLM/CosyVoice2-0.5B:alex\", \"output_dir\": \"tmp/\", \"access_token\": \"\", \"response_format\": \"wav\"}', NULL, NULL, 3, NULL, NULL, NULL, NULL); INSERT INTO `ai_model_config` VALUES ('TTS_CozeCnTTS', 'TTS', 'CozeCnTTS', 'Coze中文语音合成', 0, 1, '{\"type\": \"cozecn\", \"voice\": \"7426720361733046281\", \"output_dir\": \"tmp/\", \"access_token\": \"\", \"response_format\": \"wav\"}', NULL, NULL, 4, NULL, NULL, NULL, NULL); -INSERT INTO `ai_model_config` VALUES ('TTS_FishSpeech', 'TTS', 'FishSpeech', 'FishSpeech语音合成', 0, 1, '{\"type\": \"fishspeech\", \"output_dir\": \"tmp/\", \"response_format\": \"wav\", \"reference_id\": null, \"reference_audio\": [\"/tmp/test.wav\"], \"reference_text\": [\"你弄来这些吟词宴曲来看,还是这些混话来欺负我。\"], \"normalize\": true, \"max_new_tokens\": 1024, \"chunk_length\": 200, \"top_p\": 0.7, \"repetition_penalty\": 1.2, \"temperature\": 0.7, \"streaming\": false, \"use_memory_cache\": \"on\", \"seed\": null, \"channels\": 1, \"rate\": 44100, \"api_key\": \"\", \"api_url\": \"http://127.0.0.1:8080/v1/tts\"}', NULL, NULL, 5, NULL, NULL, NULL, NULL); +INSERT INTO `ai_model_config` VALUES ('TTS_FishSpeech', 'TTS', 'FishSpeech', 'FishSpeech语音合成', 0, 1, '{\"type\": \"fishspeech\", \"output_dir\": \"tmp/\", \"response_format\": \"wav\", \"reference_id\": null, \"reference_audio\": [\"/tmp/test.wav\"], \"reference_text\": [\"你弄来这些吟词宴曲来看,还是这些混话来欺负我。\"], \"normalize\": true, \"max_new_tokens\": 1024, \"chunk_length\": 200, \"top_p\": 0.7, \"repetition_penalty\": 1.2, \"temperature\": 0.7, \"streaming\": false, \"use_memory_cache\": \"on\", \"seed\": 1, \"channels\": 1, \"rate\": 44100, \"api_key\": \"\", \"api_url\": \"http://127.0.0.1:8080/v1/tts\"}', NULL, NULL, 5, NULL, NULL, NULL, NULL); INSERT INTO `ai_model_config` VALUES ('TTS_GPT_SOVITS_V2', 'TTS', 'GPT_SOVITS_V2', 'GPT-SoVITS V2', 0, 1, '{\"type\": \"gpt_sovits_v2\", \"url\": \"http://127.0.0.1:9880/tts\", \"output_dir\": \"tmp/\", \"text_lang\": \"auto\", \"ref_audio_path\": \"caixukun.wav\", \"prompt_text\": \"\", \"prompt_lang\": \"zh\", \"top_k\": 5, \"top_p\": 1, \"temperature\": 1, \"text_split_method\": \"cut0\", \"batch_size\": 1, \"batch_threshold\": 0.75, \"split_bucket\": true, \"return_fragment\": false, \"speed_factor\": 1.0, \"streaming_mode\": false, \"seed\": -1, \"parallel_infer\": true, \"repetition_penalty\": 1.35, \"aux_ref_audio_paths\": []}', NULL, NULL, 6, NULL, NULL, NULL, NULL); INSERT INTO `ai_model_config` VALUES ('TTS_GPT_SOVITS_V3', 'TTS', 'GPT_SOVITS_V3', 'GPT-SoVITS V3', 0, 1, '{\"type\": \"gpt_sovits_v3\", \"url\": \"http://127.0.0.1:9880\", \"output_dir\": \"tmp/\", \"text_language\": \"auto\", \"refer_wav_path\": \"caixukun.wav\", \"prompt_language\": \"zh\", \"prompt_text\": \"\", \"top_k\": 15, \"top_p\": 1.0, \"temperature\": 1.0, \"cut_punc\": \"\", \"speed\": 1.0, \"inp_refs\": [], \"sample_steps\": 32, \"if_sr\": false}', NULL, NULL, 7, NULL, NULL, NULL, NULL); INSERT INTO `ai_model_config` VALUES ('TTS_MinimaxTTS', 'TTS', 'MinimaxTTS', 'MiniMax语音合成', 0, 1, '{\"type\": \"minimax\", \"output_dir\": \"tmp/\", \"group_id\": \"\", \"api_key\": \"你的api_key\", \"model\": \"speech-01-turbo\", \"voice_id\": \"female-shaonv\"}', NULL, NULL, 8, NULL, NULL, NULL, NULL); diff --git a/main/manager-web/src/views/ProviderManagement.vue b/main/manager-web/src/views/ProviderManagement.vue index a476c5e2..7634b511 100644 --- a/main/manager-web/src/views/ProviderManagement.vue +++ b/main/manager-web/src/views/ProviderManagement.vue @@ -26,7 +26,7 @@
- + diff --git a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py index 195d058a..2a3e023f 100644 --- a/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py +++ b/main/xiaozhi-server/core/providers/intent/intent_llm/intent_llm.py @@ -218,6 +218,15 @@ class IntentProvider(IntentProviderBase): f"llm 识别到意图: {function_name}, 参数: {function_args}" ) + # 如果是继续聊天,清理工具调用相关的历史消息 + if function_name == "continue_chat": + # 保留非工具相关的消息 + clean_history = [ + msg for msg in conn.dialogue.dialogue + if msg.role not in ["tool", "function"] + ] + conn.dialogue.dialogue = clean_history + # 添加到缓存 self.intent_cache[cache_key] = { "intent": intent, diff --git a/main/xiaozhi-server/core/providers/llm/ollama/ollama.py b/main/xiaozhi-server/core/providers/llm/ollama/ollama.py index fb34ced5..aad6354f 100644 --- a/main/xiaozhi-server/core/providers/llm/ollama/ollama.py +++ b/main/xiaozhi-server/core/providers/llm/ollama/ollama.py @@ -21,27 +21,67 @@ class LLMProvider(LLMProviderBase): api_key="ollama" # Ollama doesn't need an API key but OpenAI client requires one ) + # 检查是否是qwen3模型 + self.is_qwen3 = self.model_name and self.model_name.lower().startswith("qwen3") + def response(self, session_id, dialogue): try: + # 如果是qwen3模型,在用户最后一条消息中添加/no_think指令 + if self.is_qwen3: + # 复制对话列表,避免修改原始对话 + dialogue_copy = dialogue.copy() + + # 找到最后一条用户消息 + for i in range(len(dialogue_copy) - 1, -1, -1): + if dialogue_copy[i]["role"] == "user": + # 在用户消息前添加/no_think指令 + dialogue_copy[i]["content"] = "/no_think " + dialogue_copy[i]["content"] + logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令") + break + + # 使用修改后的对话 + dialogue = dialogue_copy + responses = self.client.chat.completions.create( model=self.model_name, messages=dialogue, stream=True ) - is_active=True + is_active = True + # 用于处理跨chunk的标签 + buffer = "" + for chunk in responses: try: delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None content = delta.content if hasattr(delta, 'content') else '' + if content: - if '' in content: + # 将内容添加到缓冲区 + buffer += content + + # 处理缓冲区中的标签 + while '' in buffer and '' in buffer: + # 找到完整的标签并移除 + pre = buffer.split('', 1)[0] + post = buffer.split('', 1)[1] + buffer = pre + post + + # 处理只有开始标签的情况 + if '' in buffer: is_active = False - content = content.split('')[0] - if '' in content: + buffer = buffer.split('', 1)[0] + + # 处理只有结束标签的情况 + if '' in buffer: is_active = True - content = content.split('')[-1] - if is_active: - yield content + buffer = buffer.split('', 1)[1] + + # 如果当前处于活动状态且缓冲区有内容,则输出 + if is_active and buffer: + yield buffer + buffer = "" # 清空缓冲区 + except Exception as e: logger.bind(tag=TAG).error(f"Error processing chunk: {e}") @@ -51,6 +91,22 @@ class LLMProvider(LLMProviderBase): def response_with_functions(self, session_id, dialogue, functions=None): try: + # 如果是qwen3模型,在用户最后一条消息中添加/no_think指令 + if self.is_qwen3: + # 复制对话列表,避免修改原始对话 + dialogue_copy = dialogue.copy() + + # 找到最后一条用户消息 + for i in range(len(dialogue_copy) - 1, -1, -1): + if dialogue_copy[i]["role"] == "user": + # 在用户消息前添加/no_think指令 + dialogue_copy[i]["content"] = "/no_think " + dialogue_copy[i]["content"] + logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令") + break + + # 使用修改后的对话 + dialogue = dialogue_copy + stream = self.client.chat.completions.create( model=self.model_name, messages=dialogue, @@ -58,8 +114,49 @@ class LLMProvider(LLMProviderBase): tools=functions, ) + is_active = True + buffer = "" + for chunk in stream: - yield chunk.choices[0].delta.content, chunk.choices[0].delta.tool_calls + try: + delta = chunk.choices[0].delta if getattr(chunk, 'choices', None) else None + content = delta.content if hasattr(delta, 'content') else None + tool_calls = delta.tool_calls if hasattr(delta, 'tool_calls') else None + + # 如果是工具调用,直接传递 + if tool_calls: + yield None, tool_calls + continue + + # 处理文本内容 + if content: + # 将内容添加到缓冲区 + buffer += content + + # 处理缓冲区中的标签 + while '' in buffer and '' in buffer: + # 找到完整的标签并移除 + pre = buffer.split('', 1)[0] + post = buffer.split('', 1)[1] + buffer = pre + post + + # 处理只有开始标签的情况 + if '' in buffer: + is_active = False + buffer = buffer.split('', 1)[0] + + # 处理只有结束标签的情况 + if '' in buffer: + is_active = True + buffer = buffer.split('', 1)[1] + + # 如果当前处于活动状态且缓冲区有内容,则输出 + if is_active and buffer: + yield buffer, None + buffer = "" # 清空缓冲区 + except Exception as e: + logger.bind(tag=TAG).error(f"Error processing function chunk: {e}") + continue except Exception as e: logger.bind(tag=TAG).error(f"Error in Ollama function call: {e}")