diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 2f19434c9d..547b34ad97 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -525,6 +525,13 @@ class Reply(BaseMessageComponent): def __init__(self, **_) -> None: super().__init__(**_) + async def to_dict(self) -> dict: + chain = self.chain if self.chain is not None else [] + return { + "type": self.type.lower(), + "data": {"id": self.id, "chain": [await comp.to_dict() for comp in chain]}, + } + class Poke(BaseMessageComponent): type: ComponentType = ComponentType.Poke @@ -650,11 +657,30 @@ async def to_dict(self) -> dict: class Json(BaseMessageComponent): type: ComponentType = ComponentType.Json data: dict + raw_data: str | None = None def __init__(self, data: str | dict, **_) -> None: + raw_data = None if isinstance(data, str): - data = json.loads(data) - super().__init__(data=data, **_) + raw_data = data + try: + data = json.loads(data) + except json.JSONDecodeError: + data = {"raw": data} + super().__init__(data=data, raw_data=raw_data, **_) + + async def to_dict(self) -> dict: + # 如果原始数据是字符串,使用 content 包装形式 + if self.raw_data is not None: + return { + "type": self.type.lower(), + "data": {"content": self.raw_data}, + } + # 如果原始数据是字典,直接返回原始字典结构 + return { + "type": self.type.lower(), + "data": self.data, + } class Unknown(BaseMessageComponent): diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 117cfb4922..34a920e68d 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -14,7 +14,6 @@ import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai._exceptions import NotFoundError from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion_chunk import ChatCompletionChunk @@ -495,6 +494,8 @@ def __init__(self, provider_config, provider_settings) -> None: self.reasoning_key = "reasoning_content" +<<<<<<< HEAD +======= def _ollama_disable_thinking_enabled(self) -> bool: value = self.provider_config.get("ollama_disable_thinking", False) if isinstance(value, str): @@ -562,6 +563,7 @@ def _is_empty(content: Any) -> bool: payloads["messages"] = cleaned +>>>>>>> master async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: model = payloads.get("model", "").lower() @@ -758,8 +760,7 @@ def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage: output=completion_tokens, ) - @staticmethod - def _normalize_content(raw_content: Any, strip: bool = True) -> str: + def _normalize_content(self, raw_content: Any, strip: bool = True) -> str: """Normalize content from various formats to plain string. Some LLM providers return content as list[dict] format @@ -848,6 +849,31 @@ def _normalize_content(raw_content: Any, strip: bool = True) -> str: # Fallback for other types (int, float, etc.) return str(raw_content) if raw_content is not None else "" + def _parse_image_url_part(self, image_field) -> str | None: + """解析 OpenAI image_url 部分并提取 URL + + Args: + image_field: 可以是字典或字符串格式的 image_url 字段 + + Returns: + 提取的 URL 或 base64 数据,如果无效则返回 None + """ + if isinstance(image_field, dict): + url = image_field.get("url") + else: + url = image_field + + if not url: + return None + + # 统一处理 base64 格式,提取纯 base64 数据 + if isinstance(url, str) and "base64," in url: + return url.split("base64,", 1)[1] + elif isinstance(url, str) and url.startswith("base64://"): + return url.replace("base64://", "") + else: + return url + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: @@ -862,6 +888,58 @@ async def _parse_openai_completion( # parse the text completion if choice.message.content is not None: +<<<<<<< HEAD + # content can be either a plain string or a multimodal list + content = choice.message.content + # handle multimodal content returned as a list of parts + if isinstance(content, list): + reasoning_parts = [] + mc = MessageChain() + for part in content: + if not isinstance(part, dict): + # fallback: append as plain text + mc.message(str(part)) + continue + ptype = part.get("type") + if ptype == "text": + mc.message(part.get("text", "")) + elif ptype == "image_url": + image_field = part.get("image_url") + url = self._parse_image_url_part(image_field) + if url: + # 判断是 base64 数据还是 URL + if url.startswith("http"): + mc.url_image(url) + else: + mc.base64_image(url) + elif ptype == "think": + # collect reasoning parts for later extraction + think_val = part.get("think") + if think_val: + reasoning_parts.append(str(think_val)) + else: + # unknown part type, append its textual representation + mc.message(json.dumps(part, ensure_ascii=False)) + + if reasoning_parts: + llm_response.reasoning_content = "\n".join( + [rp.strip() for rp in reasoning_parts] + ) + llm_response.result_chain = mc + else: + # text completion (string) + completion_text = str(content).strip() + # specially, some providers may set tags around reasoning content in the completion text, + # we use regex to remove them, and store then in reasoning_content field + reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) + matches = reasoning_pattern.findall(completion_text) + if matches: + llm_response.reasoning_content = "\n".join( + [match.strip() for match in matches], + ) + completion_text = reasoning_pattern.sub("", completion_text).strip() + llm_response.result_chain = MessageChain().message(completion_text) +======= completion_text = self._normalize_content(choice.message.content) # specially, some providers may set tags around reasoning content in the completion text, # we use regex to remove them, and store then in reasoning_content field @@ -879,6 +957,7 @@ async def _parse_openai_completion( refusal_text = self._normalize_content(refusal) if refusal_text: llm_response.result_chain = MessageChain().message(refusal_text) +>>>>>>> master # parse the reasoning content if any # the priority is higher than the tag extraction diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue index 95aa77f666..58980052f9 100644 --- a/dashboard/src/components/shared/PersonaForm.vue +++ b/dashboard/src/components/shared/PersonaForm.vue @@ -644,16 +644,26 @@ export default { } this.saving = true; - try { - const url = this.editingPersona ? '/api/persona/update' : '/api/persona/create'; - const response = await axios.post(url, this.personaForm); - - if (response.data.status === 'ok') { - this.$emit('saved', response.data.message || this.tm('messages.saveSuccess')); - this.closeDialog(); - } else { - this.$emit('error', response.data.message || this.tm('messages.saveError')); + try { + const url = this.editingPersona ? '/api/persona/update' : '/api/persona/create'; + + // 白名单过滤字段 + const allowedFields = ['persona_id', 'system_prompt', 'begin_dialogs', 'tools']; + const filteredData = {}; + allowedFields.forEach(field => { + if (this.personaForm.hasOwnProperty(field)) { + filteredData[field] = this.personaForm[field]; } + }); + + const response = await axios.post(url, filteredData); + + if (response.data.status === 'ok') { + this.$emit('saved', response.data.message || this.tm('messages.saveSuccess')); + this.closeDialog(); + } else { + this.$emit('error', response.data.message || this.tm('messages.saveError')); + } } catch (error) { this.$emit('error', error.response?.data?.message || this.tm('messages.saveError')); } diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json index 3dc865c025..f208c3f381 100644 --- a/dashboard/src/i18n/locales/en-US/features/persona.json +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -10,7 +10,8 @@ "cancel": "Cancel", "save": "Save", "move": "Move", - "addDialogPair": "Add Dialog Pair" + "addDialogPair": "Add Dialog Pair", + "import": "Import" }, "labels": { "presetDialogs": "Preset Dialogs ({count} pairs)", @@ -83,18 +84,24 @@ "saveError": "Save failed", "deleteConfirm": "Are you sure you want to delete persona \"{id}\"? This action cannot be undone.", "deleteSuccess": "Deleted successfully", - "deleteError": "Delete failed" + "deleteError": "Delete failed", + "importExists": "Persona with ID {id} already exists." }, "persona": { "personasTitle": "Personas", "toolsCount": "tools", "skillsCount": "skills", "contextMenu": { - "moveTo": "Move to..." + "moveTo": "Move to...", + "export": "Export" }, "messages": { "moveSuccess": "Persona moved successfully", - "moveError": "Failed to move persona" + "moveError": "Failed to move persona", + "exportSuccess": "Exported successfully(excluding skills and tools)", + "exportError": "Failed to export persona", + "importSuccess": "Imported successfully", + "importError": "Failed to import persona" } }, "folder": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index e707a5d621..6d19f51b55 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -10,7 +10,8 @@ "cancel": "取消", "save": "保存", "move": "移动", - "addDialogPair": "添加对话对" + "addDialogPair": "添加对话对", + "import": "导入" }, "labels": { "presetDialogs": "预设对话 ({count} 对)", @@ -83,18 +84,24 @@ "saveError": "保存失败", "deleteConfirm": "确定要删除人格 \"{id}\" 吗?此操作不可撤销。", "deleteSuccess": "删除成功", - "deleteError": "删除失败" + "deleteError": "删除失败", + "importExists": "人格 ID {id} 已存在" }, "persona": { "personasTitle": "人格", "toolsCount": "个工具", "skillsCount": "个 Skills", "contextMenu": { - "moveTo": "移动到..." + "moveTo": "移动到...", + "export": "导出" }, "messages": { "moveSuccess": "人格移动成功", - "moveError": "移动人格失败" + "moveError": "移动人格失败", + "exportSuccess": "导出成功(不包含 Skills 和工具)", + "exportError": "导出失败", + "importSuccess": "导入成功", + "importError": "导入失败" } }, "folder": { diff --git a/dashboard/src/stores/personaStore.ts b/dashboard/src/stores/personaStore.ts index 4f6f7985e6..35da23f455 100644 --- a/dashboard/src/stores/personaStore.ts +++ b/dashboard/src/stores/personaStore.ts @@ -330,5 +330,27 @@ export const usePersonaStore = defineStore("persona", { }; return findNode(this.folderTree); }, + + /** + * 导入人格数据 + */ + async importPersona(data: Partial): Promise { + const response = await axios.post('/api/persona/create', { + persona_id: data.persona_id, + system_prompt: data.system_prompt, + begin_dialogs: data.begin_dialogs || [], + tools: data.tools, + skills: data.skills, + }); + + if (response.data.status !== 'ok') { + throw new Error(response.data.message || '导入人格失败'); + } + + // 刷新当前文件夹内容 + await this.refreshCurrentFolder(); + + return response.data.data.persona; + }, } }); diff --git a/dashboard/src/views/persona/PersonaCard.vue b/dashboard/src/views/persona/PersonaCard.vue index 7cebb26a9d..dd342a8dd4 100644 --- a/dashboard/src/views/persona/PersonaCard.vue +++ b/dashboard/src/views/persona/PersonaCard.vue @@ -14,12 +14,18 @@ {{ tm('buttons.edit') }} - + {{ tm('persona.contextMenu.moveTo') }} + + + {{ tm('persona.contextMenu.export') }} +