Compare commits

...

16 Commits

Author SHA1 Message Date
Junyan Qin
127a38b15c chore: bump version 4.3.9 2025-10-22 18:52:45 +08:00
fdc310
e4729337c8 Feat/coze runner (#1714)
* feat:add coze api client and coze runner and coze config

* del print

* fix:Change the default setting of the plugin system to true

* fix:del multimodal-support config, default multimodal-support,and in cozeapi.py Obtain timeout and auto-save-history config

* chore: add comment for coze.com

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-10-17 18:13:03 +08:00
Junyan Qin
5fa75330cf perf: store pipeline sort method 2025-10-12 21:11:30 +08:00
Junyan Qin
547e3d098e perf: add component list in plugin detail dialog 2025-10-12 19:57:42 +08:00
Junyan Qin
f1ddddfe00 chore: bump version 4.3.8 2025-10-10 22:50:57 +08:00
Junyan Qin
4e61302156 fix: datetime serialization error in emit_event (#1713) 2025-10-10 22:37:39 +08:00
Junyan Qin
9e3cf418ba perf: output pipeline error in en 2025-10-10 17:55:49 +08:00
Junyan Qin
3e29ec7892 perf: allow not set llm model (#1703) 2025-10-10 16:34:01 +08:00
Junyan Qin
f452742cd2 fix: bad Plain component init in wechatpad (#1712) 2025-10-10 14:48:21 +08:00
Junyan Qin
b560432b0b chore: bump version 4.3.7 2025-10-08 14:36:48 +08:00
Junyan Qin (Chin)
99e5478ced fix: return empty data when plugin system disabled (#1710) 2025-10-07 16:24:38 +08:00
Junyan Qin
09dba91a37 chore: bump version 4.3.7b1 2025-10-07 15:30:33 +08:00
Junyan Qin
18ec4adac9 chore: bump langbot-plugin to 0.1.4b2 2025-10-07 15:25:49 +08:00
Junyan Qin
8bedaa468a chore: add codecov.yml 2025-10-07 00:15:56 +08:00
Guanchao Wang
0ab366fcac Fix/qqo (#1709)
* fix: qq official

* fix: appid
2025-10-07 00:06:07 +08:00
Junyan Qin
d664039e54 feat: update for new events fields 2025-10-06 23:22:38 +08:00
32 changed files with 795 additions and 137 deletions

4
codecov.yml Normal file
View File

@@ -0,0 +1,4 @@
coverage:
status:
project: off
patch: off

View File

View File

@@ -0,0 +1,192 @@
import json
import asyncio
import aiohttp
import io
from typing import Dict, List, Any, AsyncGenerator
import os
from pathlib import Path
class AsyncCozeAPIClient:
def __init__(self, api_key: str, api_base: str = "https://api.coze.cn"):
self.api_key = api_key
self.api_base = api_base
self.session = None
async def __aenter__(self):
"""支持异步上下文管理器"""
await self.coze_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""退出时自动关闭会话"""
await self.close()
async def coze_session(self):
"""确保HTTP session存在"""
if self.session is None:
connector = aiohttp.TCPConnector(
ssl=False if self.api_base.startswith("http://") else True,
limit=100,
limit_per_host=30,
keepalive_timeout=30,
enable_cleanup_closed=True,
)
timeout = aiohttp.ClientTimeout(
total=120, # 默认超时时间
connect=30,
sock_read=120,
)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "text/event-stream",
}
self.session = aiohttp.ClientSession(
headers=headers, timeout=timeout, connector=connector
)
return self.session
async def close(self):
"""显式关闭会话"""
if self.session and not self.session.closed:
await self.session.close()
self.session = None
async def upload(
self,
file,
) -> str:
# 处理 Path 对象
if isinstance(file, Path):
if not file.exists():
raise ValueError(f"File not found: {file}")
with open(file, "rb") as f:
file = f.read()
# 处理文件路径字符串
elif isinstance(file, str):
if not os.path.isfile(file):
raise ValueError(f"File not found: {file}")
with open(file, "rb") as f:
file = f.read()
# 处理文件对象
elif hasattr(file, 'read'):
file = file.read()
session = await self.coze_session()
url = f"{self.api_base}/v1/files/upload"
try:
file_io = io.BytesIO(file)
async with session.post(
url,
data={
"file": file_io,
},
timeout=aiohttp.ClientTimeout(total=60),
) as response:
if response.status == 401:
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
response_text = await response.text()
if response.status != 200:
raise Exception(
f"文件上传失败,状态码: {response.status}, 响应: {response_text}"
)
try:
result = await response.json()
except json.JSONDecodeError:
raise Exception(f"文件上传响应解析失败: {response_text}")
if result.get("code") != 0:
raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}")
file_id = result["data"]["id"]
return file_id
except asyncio.TimeoutError:
raise Exception("文件上传超时")
except Exception as e:
raise Exception(f"文件上传失败: {str(e)}")
async def chat_messages(
self,
bot_id: str,
user_id: str,
additional_messages: List[Dict] | None = None,
conversation_id: str | None = None,
auto_save_history: bool = True,
stream: bool = True,
timeout: float = 120,
) -> AsyncGenerator[Dict[str, Any], None]:
"""发送聊天消息并返回流式响应
Args:
bot_id: Bot ID
user_id: 用户ID
additional_messages: 额外消息列表
conversation_id: 会话ID
auto_save_history: 是否自动保存历史
stream: 是否流式响应
timeout: 超时时间
"""
session = await self.coze_session()
url = f"{self.api_base}/v3/chat"
payload = {
"bot_id": bot_id,
"user_id": user_id,
"stream": stream,
"auto_save_history": auto_save_history,
}
if additional_messages:
payload["additional_messages"] = additional_messages
params = {}
if conversation_id:
params["conversation_id"] = conversation_id
try:
async with session.post(
url,
json=payload,
params=params,
timeout=aiohttp.ClientTimeout(total=timeout),
) as response:
if response.status == 401:
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
if response.status != 200:
raise Exception(f"Coze API 流式请求失败,状态码: {response.status}")
async for chunk in response.content:
chunk = chunk.decode("utf-8")
if chunk != '\n':
if chunk.startswith("event:"):
chunk_type = chunk.replace("event:", "", 1).strip()
elif chunk.startswith("data:"):
chunk_data = chunk.replace("data:", "", 1).strip()
else:
yield {"event": chunk_type, "data": json.loads(chunk_data)}
except asyncio.TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
except Exception as e:
raise Exception(f"Coze API 流式请求失败: {str(e)}")

View File

@@ -213,7 +213,7 @@ class RuntimePipeline:
await self._execute_from_stage(0, query) await self._execute_from_stage(0, query)
except Exception as e: except Exception as e:
inst_name = query.current_stage_name if query.current_stage_name else 'unknown' inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
self.ap.logger.error(f'处理请求时出错 query_id={query.query_id} stage={inst_name} : {e}') self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
self.ap.logger.error(f'Traceback: {traceback.format_exc()}') self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
finally: finally:
self.ap.logger.debug(f'Query {query.query_id} processed') self.ap.logger.debug(f'Query {query.query_id} processed')

View File

@@ -35,11 +35,17 @@ class PreProcessor(stage.PipelineStage):
session = await self.ap.sess_mgr.get_session(query) session = await self.ap.sess_mgr.get_session(query)
# When not local-agent, llm_model is None # When not local-agent, llm_model is None
llm_model = ( try:
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model']) llm_model = (
if selected_runner == 'local-agent' await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
else None if selected_runner == 'local-agent'
) else None
)
except ValueError:
self.ap.logger.warning(
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
)
llm_model = None
conversation = await self.ap.sess_mgr.get_conversation( conversation = await self.ap.sess_mgr.get_conversation(
query, query,
@@ -54,7 +60,7 @@ class PreProcessor(stage.PipelineStage):
query.prompt = conversation.prompt.copy() query.prompt = conversation.prompt.copy()
query.messages = conversation.messages.copy() query.messages = conversation.messages.copy()
if selected_runner == 'local-agent': if selected_runner == 'local-agent' and llm_model:
query.use_funcs = [] query.use_funcs = []
query.use_llm_model_uuid = llm_model.model_entity.uuid query.use_llm_model_uuid = llm_model.model_entity.uuid
@@ -72,7 +78,11 @@ class PreProcessor(stage.PipelineStage):
# Check if this model supports vision, if not, remove all images # Check if this model supports vision, if not, remove all images
# TODO this checking should be performed in runner, and in this stage, the image should be reserved # TODO this checking should be performed in runner, and in this stage, the image should be reserved
if selected_runner == 'local-agent' and not llm_model.model_entity.abilities.__contains__('vision'): if (
selected_runner == 'local-agent'
and llm_model
and not llm_model.model_entity.abilities.__contains__('vision')
):
for msg in query.messages: for msg in query.messages:
if isinstance(msg.content, list): if isinstance(msg.content, list):
for me in msg.content: for me in msg.content:
@@ -89,7 +99,9 @@ class PreProcessor(stage.PipelineStage):
content_list.append(provider_message.ContentElement.from_text(me.text)) content_list.append(provider_message.ContentElement.from_text(me.text))
plain_text += me.text plain_text += me.text
elif isinstance(me, platform_message.Image): elif isinstance(me, platform_message.Image):
if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'): if selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if me.base64 is not None: if me.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(me.base64)) content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
elif isinstance(me, platform_message.File): elif isinstance(me, platform_message.File):
@@ -100,7 +112,9 @@ class PreProcessor(stage.PipelineStage):
if isinstance(msg, platform_message.Plain): if isinstance(msg, platform_message.Plain):
content_list.append(provider_message.ContentElement.from_text(msg.text)) content_list.append(provider_message.ContentElement.from_text(msg.text))
elif isinstance(msg, platform_message.Image): elif isinstance(msg, platform_message.Image):
if selected_runner != 'local-agent' or llm_model.model_entity.abilities.__contains__('vision'): if selected_runner != 'local-agent' or (
llm_model and llm_model.model_entity.abilities.__contains__('vision')
):
if msg.base64 is not None: if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))

View File

@@ -9,7 +9,6 @@ from .. import handler
from ... import entities from ... import entities
from ....provider import runner as runner_module from ....provider import runner as runner_module
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
from ....utils import importutil from ....utils import importutil
from ....provider import runners from ....provider import runners
@@ -47,18 +46,19 @@ class ChatMessageHandler(handler.MessageHandler):
event_ctx = await self.ap.plugin_connector.emit_event(event) event_ctx = await self.ap.plugin_connector.emit_event(event)
is_create_card = False # 判断下是否需要创建流式卡片 is_create_card = False # 判断下是否需要创建流式卡片
if event_ctx.is_prevented_default(): if event_ctx.is_prevented_default():
if event_ctx.event.reply is not None: if event_ctx.event.reply_message_chain is not None:
mc = platform_message.MessageChain(event_ctx.event.reply) mc = event_ctx.event.reply_message_chain
query.resp_messages.append(mc) query.resp_messages.append(mc)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
else: else:
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
else: else:
if event_ctx.event.alter is not None: if event_ctx.event.user_message_alter is not None:
# if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter # if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter
query.user_message.content = event_ctx.event.alter query.user_message.content = event_ctx.event.user_message_alter
text_length = 0 text_length = 0
try: try:

View File

@@ -5,7 +5,6 @@ import typing
from .. import handler from .. import handler
from ... import entities from ... import entities
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
@@ -49,8 +48,8 @@ class CommandHandler(handler.MessageHandler):
event_ctx = await self.ap.plugin_connector.emit_event(event) event_ctx = await self.ap.plugin_connector.emit_event(event)
if event_ctx.is_prevented_default(): if event_ctx.is_prevented_default():
if event_ctx.event.reply is not None: if event_ctx.event.reply_message_chain is not None:
mc = platform_message.MessageChain(event_ctx.event.reply) mc = event_ctx.event.reply_message_chain
query.resp_messages.append(mc) query.resp_messages.append(mc)
@@ -59,11 +58,6 @@ class CommandHandler(handler.MessageHandler):
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
else: else:
if event_ctx.event.alter is not None:
query.message_chain = platform_message.MessageChain(
[platform_message.Plain(text=event_ctx.event.alter)]
)
session = await self.ap.sess_mgr.get_session(query) session = await self.ap.sess_mgr.get_session(query)
async for ret in self.ap.cmd_mgr.execute( async for ret in self.ap.cmd_mgr.execute(
@@ -80,8 +74,12 @@ class CommandHandler(handler.MessageHandler):
self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}') self.ap.logger.info(f'Command({query.query_id}) error: {self.cut_str(str(ret.error))}')
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
elif (ret.text is not None or ret.image_url is not None or ret.image_base64 is not None elif (
or ret.file_url is not None): ret.text is not None
or ret.image_url is not None
or ret.image_base64 is not None
or ret.file_url is not None
):
content: list[provider_message.ContentElement] = [] content: list[provider_message.ContentElement] = []
if ret.text is not None: if ret.text is not None:

View File

@@ -80,8 +80,8 @@ class ResponseWrapper(stage.PipelineStage):
new_query=query, new_query=query,
) )
else: else:
if event_ctx.event.reply is not None: if event_ctx.event.reply_message_chain is not None:
query.resp_message_chain.append(platform_message.MessageChain(event_ctx.event.reply)) query.resp_message_chain.append(event_ctx.event.reply_message_chain)
else: else:
query.resp_message_chain.append(result.get_content_platform_message_chain()) query.resp_message_chain.append(result.get_content_platform_message_chain())
@@ -123,10 +123,8 @@ class ResponseWrapper(stage.PipelineStage):
new_query=query, new_query=query,
) )
else: else:
if event_ctx.event.reply is not None: if event_ctx.event.reply_message_chain is not None:
query.resp_message_chain.append( query.resp_message_chain.append(event_ctx.event.reply_message_chain)
platform_message.MessageChain(text=event_ctx.event.reply)
)
else: else:
query.resp_message_chain.append( query.resp_message_chain.append(

View File

@@ -139,19 +139,15 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
event_converter: QQOfficialEventConverter = QQOfficialEventConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger):
self.config = config bot = QQOfficialClient(
self.logger = logger app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger
)
required_keys = [ super().__init__(
'appid', config=config,
'secret', logger=logger,
] bot=bot,
missing_keys = [key for key in required_keys if key not in config] bot_account_id=config['appid'],
if missing_keys:
raise command_errors.ParamNotEnoughError('QQ官方机器人缺少相关配置项请查看文档或联系管理员')
self.bot = QQOfficialClient(
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=self.logger
) )
async def reply_message( async def reply_message(

View File

@@ -139,7 +139,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
pattern = r'@\S{1,20}' pattern = r'@\S{1,20}'
content_no_preifx = re.sub(pattern, '', content_no_preifx) content_no_preifx = re.sub(pattern, '', content_no_preifx)
return platform_message.MessageChain([platform_message.Plain(content_no_preifx)]) return platform_message.MessageChain([platform_message.Plain(text=content_no_preifx)])
async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain: async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
"""处理图像消息 (msg_type=3)""" """处理图像消息 (msg_type=3)"""
@@ -265,7 +265,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
# 文本消息 # 文本消息
try: try:
if '<msg>' not in quote_data: if '<msg>' not in quote_data:
quote_data_message_list.append(platform_message.Plain(quote_data)) quote_data_message_list.append(platform_message.Plain(text=quote_data))
else: else:
# 引用消息展开 # 引用消息展开
quote_data_xml = ET.fromstring(quote_data) quote_data_xml = ET.fromstring(quote_data)
@@ -280,7 +280,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
quote_data_message_list.extend(await self._handler_compound(None, quote_data)) quote_data_message_list.extend(await self._handler_compound(None, quote_data))
except Exception as e: except Exception as e:
self.logger.error(f'处理引用消息异常 expcetion:{e}') self.logger.error(f'处理引用消息异常 expcetion:{e}')
quote_data_message_list.append(platform_message.Plain(quote_data)) quote_data_message_list.append(platform_message.Plain(text=quote_data))
message_list.append( message_list.append(
platform_message.Quote( platform_message.Quote(
sender_id=sender_id, sender_id=sender_id,
@@ -290,7 +290,7 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
if len(user_data) > 0: if len(user_data) > 0:
pattern = r'@\S{1,20}' pattern = r'@\S{1,20}'
user_data = re.sub(pattern, '', user_data) user_data = re.sub(pattern, '', user_data)
message_list.append(platform_message.Plain(user_data)) message_list.append(platform_message.Plain(text=user_data))
return platform_message.MessageChain(message_list) return platform_message.MessageChain(message_list)
@@ -543,7 +543,6 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
] = {} ] = {}
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger):
quart_app = quart.Quart(__name__) quart_app = quart.Quart(__name__)
message_converter = WeChatPadMessageConverter(config, logger) message_converter = WeChatPadMessageConverter(config, logger)
@@ -551,15 +550,14 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
bot = WeChatPadClient(config['wechatpad_url'], config['token']) bot = WeChatPadClient(config['wechatpad_url'], config['token'])
super().__init__( super().__init__(
config=config, config=config,
logger = logger, logger=logger,
quart_app = quart_app, quart_app=quart_app,
message_converter =message_converter, message_converter=message_converter,
event_converter = event_converter, event_converter=event_converter,
listeners={}, listeners={},
bot_account_id ='', bot_account_id='',
name="WeChatPad", name='WeChatPad',
bot=bot, bot=bot,
) )
async def ws_message(self, data): async def ws_message(self, data):

View File

@@ -18,7 +18,7 @@ from langbot_plugin.api.entities import events
from langbot_plugin.api.entities import context from langbot_plugin.api.entities import context
import langbot_plugin.runtime.io.connection as base_connection import langbot_plugin.runtime.io.connection as base_connection
from langbot_plugin.api.definition.components.manifest import ComponentManifest from langbot_plugin.api.definition.components.manifest import ComponentManifest
from langbot_plugin.api.entities.builtin.command import context as command_context from langbot_plugin.api.entities.builtin.command import context as command_context, errors as command_errors
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
from ..core import taskmgr from ..core import taskmgr
@@ -191,6 +191,9 @@ class PluginRuntimeConnector:
task_context.trace(trace) task_context.trace(trace)
async def list_plugins(self) -> list[dict[str, Any]]: async def list_plugins(self) -> list[dict[str, Any]]:
if not self.is_enable_plugin:
return []
return await self.handler.list_plugins() return await self.handler.list_plugins()
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]: async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
@@ -211,21 +214,31 @@ class PluginRuntimeConnector:
if not self.is_enable_plugin: if not self.is_enable_plugin:
return event_ctx return event_ctx
event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=True))
event_ctx_result = await self.handler.emit_event(event_ctx.model_dump(serialize_as_any=False))
event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
return event_ctx return event_ctx
async def list_tools(self) -> list[ComponentManifest]: async def list_tools(self) -> list[ComponentManifest]:
if not self.is_enable_plugin:
return []
list_tools_data = await self.handler.list_tools() list_tools_data = await self.handler.list_tools()
return [ComponentManifest.model_validate(tool) for tool in list_tools_data] return [ComponentManifest.model_validate(tool) for tool in list_tools_data]
async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]: async def call_tool(self, tool_name: str, parameters: dict[str, Any]) -> dict[str, Any]:
if not self.is_enable_plugin:
return {'error': 'Tool not found: plugin system is disabled'}
return await self.handler.call_tool(tool_name, parameters) return await self.handler.call_tool(tool_name, parameters)
async def list_commands(self) -> list[ComponentManifest]: async def list_commands(self) -> list[ComponentManifest]:
if not self.is_enable_plugin:
return []
list_commands_data = await self.handler.list_commands() list_commands_data = await self.handler.list_commands()
return [ComponentManifest.model_validate(command) for command in list_commands_data] return [ComponentManifest.model_validate(command) for command in list_commands_data]
@@ -233,6 +246,9 @@ class PluginRuntimeConnector:
async def execute_command( async def execute_command(
self, command_ctx: command_context.ExecuteContext self, command_ctx: command_context.ExecuteContext
) -> typing.AsyncGenerator[command_context.CommandReturn, None]: ) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
if not self.is_enable_plugin:
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True)) gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True))
async for ret in gen: async for ret in gen:

View File

@@ -0,0 +1,312 @@
from __future__ import annotations
import typing
import json
import uuid
import base64
from .. import runner
from ...core import app
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ...utils import image
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from libs.coze_server_api.client import AsyncCozeAPIClient
@runner.runner_class('coze-api')
class CozeAPIRunner(runner.RequestRunner):
"""Coze API 对话请求器"""
def __init__(self, ap: app.Application, pipeline_config: dict):
self.pipeline_config = pipeline_config
self.ap = ap
self.agent_token = pipeline_config["ai"]['coze-api']['api-key']
self.bot_id = pipeline_config["ai"]['coze-api'].get('bot-id')
self.chat_timeout = pipeline_config["ai"]['coze-api'].get('timeout')
self.auto_save_history = pipeline_config["ai"]['coze-api'].get('auto_save_history')
self.api_base = pipeline_config["ai"]['coze-api'].get('api-base')
self.coze = AsyncCozeAPIClient(
self.agent_token,
self.api_base
)
def _process_thinking_content(
self,
content: str,
) -> tuple[str, str]:
"""处理思维链内容
Args:
content: 原始内容
Returns:
(处理后的内容, 提取的思维链内容)
"""
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
thinking_content = ''
# 从 content 中提取 <think> 标签内容
if content and '<think>' in content and '</think>' in content:
import re
think_pattern = r'<think>(.*?)</think>'
think_matches = re.findall(think_pattern, content, re.DOTALL)
if think_matches:
thinking_content = '\n'.join(think_matches)
# 移除 content 中的 <think> 标签
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
# 根据 remove_think 参数决定是否保留思维链
if remove_think:
return content, ''
else:
# 如果有思维链内容,将其以 <think> 格式添加到 content 开头
if thinking_content:
content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip()
return content, thinking_content
async def _preprocess_user_message(self, query: pipeline_query.Query) -> list[dict]:
"""预处理用户消息转换为Coze消息格式
Returns:
list[dict]: Coze消息列表
"""
messages = []
if isinstance(query.user_message.content, list):
# 多模态消息处理
content_parts = []
for ce in query.user_message.content:
if ce.type == 'text':
content_parts.append({"type": "text", "text": ce.text})
elif ce.type == 'image_base64':
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
file_bytes = base64.b64decode(image_b64)
file_id = await self._get_file_id(file_bytes)
content_parts.append({"type": "image", "file_id": file_id})
elif ce.type == 'file':
# 处理文件上传到Coze
file_id = await self._get_file_id(ce.file)
content_parts.append({"type": "file", "file_id": file_id})
# 创建多模态消息
if content_parts:
messages.append({
"role": "user",
"content": json.dumps(content_parts),
"content_type": "object_string",
"meta_data": None
})
elif isinstance(query.user_message.content, str):
# 纯文本消息
messages.append({
"role": "user",
"content": query.user_message.content,
"content_type": "text",
"meta_data": None
})
return messages
async def _get_file_id(self, file) -> str:
"""上传文件到Coze服务
Args:
file: 文件
Returns:
str: 文件ID
"""
file_id = await self.coze.upload(file=file)
return file_id
async def _chat_messages(
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用聊天助手(非流式)
注意由于cozepy没有提供非流式API这里使用流式API并在结束后一次性返回完整内容
"""
user_id = f'{query.launcher_id}_{query.sender_id}'
# 预处理用户消息
additional_messages = await self._preprocess_user_message(query)
# 获取会话ID
conversation_id = None
# 收集完整内容
full_content = ''
full_reasoning = ''
try:
# 调用Coze API流式接口
async for chunk in self.coze.chat_messages(
bot_id=self.bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
timeout=self.chat_timeout,
auto_save_history=self.auto_save_history,
stream=True
):
self.ap.logger.debug(f'coze-chat-stream: {chunk}')
event_type = chunk.get('event')
data = chunk.get('data', {})
if event_type == 'conversation.message.delta':
# 收集内容
if 'content' in data:
full_content += data.get('content', '')
# 收集推理内容(如果有)
if 'reasoning_content' in data:
full_reasoning += data.get('reasoning_content', '')
elif event_type == 'done':
# 保存会话ID
if 'conversation_id' in data:
conversation_id = data.get('conversation_id')
elif event_type == 'error':
# 处理错误
error_msg = f"Coze API错误: {data.get('message', '未知错误')}"
yield provider_message.Message(
role='assistant',
content=error_msg,
)
return
# 处理思维链内容
content, thinking_content = self._process_thinking_content(full_content)
if full_reasoning:
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
if not remove_think:
content = f'<think>\n{full_reasoning}\n</think>\n{content}'.strip()
# 一次性返回完整内容
yield provider_message.Message(
role='assistant',
content=content,
)
# 保存会话ID
if conversation_id and query.session.using_conversation:
query.session.using_conversation.uuid = conversation_id
except Exception as e:
self.ap.logger.error(f'Coze API错误: {str(e)}')
yield provider_message.Message(
role='assistant',
content=f'Coze API调用失败: {str(e)}',
)
async def _chat_messages_chunk(
self, query: pipeline_query.Query
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
"""调用聊天助手(流式)"""
user_id = f'{query.launcher_id}_{query.sender_id}'
# 预处理用户消息
additional_messages = await self._preprocess_user_message(query)
# 获取会话ID
conversation_id = None
start_reasoning = False
stop_reasoning = False
message_idx = 1
is_final = False
full_content = ''
remove_think = self.pipeline_config.get('output', {}).get('misc', {}).get('remove-think', False)
try:
# 调用Coze API流式接口
async for chunk in self.coze.chat_messages(
bot_id=self.bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
timeout=self.chat_timeout,
auto_save_history=self.auto_save_history,
stream=True
):
self.ap.logger.debug(f'coze-chat-stream-chunk: {chunk}')
event_type = chunk.get('event')
data = chunk.get('data', {})
content = ""
if event_type == 'conversation.message.delta':
message_idx += 1
# 处理内容增量
if "reasoning_content" in data and not remove_think:
reasoning_content = data.get('reasoning_content', '')
if reasoning_content and not start_reasoning:
content = f"<think/>\n"
start_reasoning = True
content += reasoning_content
if 'content' in data:
if data.get('content', ''):
content += data.get('content', '')
if not stop_reasoning and start_reasoning:
content = f"</think>\n{content}"
stop_reasoning = True
elif event_type == 'done':
# 保存会话ID
if 'conversation_id' in data:
conversation_id = data.get('conversation_id')
if query.session.using_conversation:
query.session.using_conversation.uuid = conversation_id
is_final = True
elif event_type == 'error':
# 处理错误
error_msg = f"Coze API错误: {data.get('message', '未知错误')}"
yield provider_message.MessageChunk(
role='assistant',
content=error_msg,
finish_reason='error'
)
return
full_content += content
if message_idx % 8 == 0 or is_final:
if full_content:
yield provider_message.MessageChunk(
role='assistant',
content=full_content,
is_final=is_final
)
except Exception as e:
self.ap.logger.error(f'Coze API流式调用错误: {str(e)}')
yield provider_message.MessageChunk(
role='assistant',
content=f'Coze API流式调用失败: {str(e)}',
finish_reason='error'
)
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
"""运行"""
msg_seq = 0
if await query.adapter.is_stream_output_supported():
async for msg in self._chat_messages_chunk(query):
if isinstance(msg, provider_message.MessageChunk):
msg_seq += 1
msg.msg_sequence = msg_seq
yield msg
else:
async for msg in self._chat_messages(query):
yield msg

View File

@@ -1,4 +1,4 @@
semantic_version = 'v4.3.6' semantic_version = 'v4.3.9'
required_database_version = 8 required_database_version = 8
"""Tag the version of the database schema, used to check if the database needs to be migrated""" """Tag the version of the database schema, used to check if the database needs to be migrated"""

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.3.6" version = "4.3.9"
description = "Easy-to-use global IM bot platform designed for LLM era" description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10.1,<4.0" requires-python = ">=3.10.1,<4.0"
@@ -62,7 +62,7 @@ dependencies = [
"langchain>=0.2.0", "langchain>=0.2.0",
"chromadb>=0.4.24", "chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.3", "langbot-plugin==0.1.4",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10", "tboxsdk>=0.0.10",

View File

@@ -41,4 +41,4 @@ plugin:
enable: true enable: true
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true enable_marketplace: true
cloud_service_url: 'https://space.langbot.app' cloud_service_url: 'https://space.langbot.app'

View File

@@ -43,6 +43,10 @@ stages:
label: label:
en_US: Langflow API en_US: Langflow API
zh_Hans: Langflow API zh_Hans: Langflow API
- name: coze-api
label:
en_US: Coze API
zh_Hans: 扣子 API
- name: local-agent - name: local-agent
label: label:
en_US: Local Agent en_US: Local Agent
@@ -380,4 +384,57 @@ stages:
zh_Hans: 可选的流程调整参数 zh_Hans: 可选的流程调整参数
type: json type: json
required: false required: false
default: '{}' default: '{}'
- name: coze-api
label:
en_US: coze API
zh_Hans: 扣子 API
description:
en_US: Configure the Coze API of the pipeline
zh_Hans: 配置Coze API
config:
- name: api-key
label:
en_US: API Key
zh_Hans: API 密钥
description:
en_US: The API key for the Coze server
zh_Hans: Coze服务器的 API 密钥
type: string
required: true
- name: bot-id
label:
en_US: Bot ID
zh_Hans: 机器人 ID
description:
en_US: The ID of the bot to run
zh_Hans: 要运行的机器人 ID
type: string
required: true
- name: api-base
label:
en_US: API Base URL
zh_Hans: API 基础 URL
description:
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
zh_Hans: Coze API 的基础 URL请使用 https://api.coze.com 用于全球 Coze 版coze.com
type: string
default: "https://api.coze.cn"
- name: auto-save-history
label:
en_US: Auto Save History
zh_Hans: 自动保存历史
description:
en_US: Whether to automatically save conversation history
zh_Hans: 是否自动保存对话历史
type: boolean
default: true
- name: timeout
label:
en_US: Request Timeout
zh_Hans: 请求超时
description:
en_US: Timeout in seconds for API requests
zh_Hans: API 请求超时时间(秒)
type: number
default: 120

View File

@@ -29,7 +29,17 @@ export default function PluginConfigPage() {
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC'); const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
useEffect(() => { useEffect(() => {
getPipelines(); // Load sort preference from localStorage
const savedSortBy = localStorage.getItem('pipeline_sort_by');
const savedSortOrder = localStorage.getItem('pipeline_sort_order');
if (savedSortBy && savedSortOrder) {
setSortByValue(savedSortBy);
setSortOrderValue(savedSortOrder);
getPipelines(savedSortBy, savedSortOrder);
} else {
getPipelines();
}
}, []); }, []);
function getPipelines( function getPipelines(
@@ -91,6 +101,11 @@ export default function PluginConfigPage() {
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim()); const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
setSortByValue(newSortBy); setSortByValue(newSortBy);
setSortOrderValue(newSortOrder); setSortOrderValue(newSortOrder);
// Save sort preference to localStorage
localStorage.setItem('pipeline_sort_by', newSortBy);
localStorage.setItem('pipeline_sort_order', newSortOrder);
getPipelines(newSortBy, newSortOrder); getPipelines(newSortBy, newSortOrder);
} }
@@ -135,6 +150,12 @@ export default function PluginConfigPage() {
> >
{t('pipelines.newestCreated')} {t('pipelines.newestCreated')}
</SelectItem> </SelectItem>
<SelectItem
value="created_at,ASC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.earliestCreated')}
</SelectItem>
<SelectItem <SelectItem
value="updated_at,DESC" value="updated_at,DESC"
className="text-gray-900 dark:text-gray-100" className="text-gray-900 dark:text-gray-100"

View File

@@ -0,0 +1,75 @@
import { PluginComponent } from '@/app/infra/entities/plugin';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
components,
showComponentName,
showTitle,
useBadge,
t,
}: {
components: PluginComponent[];
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
}) {
const componentKindCount: Record<string, number> = {};
for (const component of components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
};
const componentKindList = Object.keys(componentKindCount);
return (
<>
{showTitle && <div>{t('plugins.componentsList')}</div>}
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<>
{useBadge && (
<Badge variant="outline">
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</Badge>
)}
{!useBadge && (
<div
key={kind}
className="flex flex-row items-center justify-start gap-[0.2rem]"
>
{kindIconMap[kind]}
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</div>
)}
</>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('plugins.noComponents')}</div>}
</>
);
}

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO'; import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/plugin-installed/plugin-card/PluginCardComponent'; import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import PluginForm from '@/app/home/plugins/plugin-installed/plugin-form/PluginForm'; import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import styles from '@/app/home/plugins/plugins.module.css'; import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { import {

View File

@@ -1,21 +1,10 @@
import { PluginCardVO } from '@/app/home/plugins/plugin-installed/PluginCardVO'; import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next'; import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
import {
AudioWaveform,
Wrench,
Hash,
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
} from 'lucide-react';
import { getCloudServiceClientSync } from '@/app/infra/http'; import { getCloudServiceClientSync } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { PluginComponent } from '@/app/infra/entities/plugin';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -23,49 +12,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
function getComponentList(components: PluginComponent[], t: TFunction) {
const componentKindCount: Record<string, number> = {};
for (const component of components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
};
const componentKindList = Object.keys(componentKindCount);
return (
<>
<div>{t('plugins.componentsList')}</div>
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<div
key={kind}
className="flex flex-row items-center justify-start gap-[0.4rem]"
>
{kindIconMap[kind]} {componentKindCount[kind]}
</div>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('plugins.noComponents')}</div>}
</>
);
}
export default function PluginCardComponent({ export default function PluginCardComponent({
cardVO, cardVO,
@@ -180,7 +127,13 @@ export default function PluginCardComponent({
</div> </div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]"> <div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
{getComponentList(cardVO.components, t)} <PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={true}
useBadge={false}
t={t}
/>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginForm({ export default function PluginForm({
pluginAuthor, pluginAuthor,
@@ -78,6 +79,17 @@ export default function PluginForm({
}, },
)} )}
</div> </div>
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
<PluginComponentList
components={pluginInfo.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
{pluginInfo.manifest.manifest.spec.config.length > 0 && ( {pluginInfo.manifest.manifest.spec.config.length > 0 && (
<DynamicFormComponent <DynamicFormComponent
itemConfigList={pluginInfo.manifest.manifest.spec.config} itemConfigList={pluginInfo.manifest.manifest.spec.config}

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import PluginInstalledComponent, { import PluginInstalledComponent, {
PluginInstalledComponentRef, PluginInstalledComponentRef,
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent'; } from '@/app/home/plugins/components/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent'; import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog'; // import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
import styles from './plugins.module.css'; import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';

View File

@@ -206,9 +206,11 @@ const enUS = {
deleteConfirm: 'Delete Confirmation', deleteConfirm: 'Delete Confirmation',
deleteSuccess: 'Delete successful', deleteSuccess: 'Delete successful',
modifyFailed: 'Modify failed: ', modifyFailed: 'Modify failed: ',
eventCount: 'Events: {{count}}', componentName: {
toolCount: 'Tools: {{count}}', Tool: 'Tool',
starCount: 'Stars: {{count}}', EventListener: 'Event Listener',
Command: 'Command',
},
uploadLocal: 'Upload Local', uploadLocal: 'Upload Local',
debugging: 'Debugging', debugging: 'Debugging',
uploadLocalPlugin: 'Upload Local Plugin', uploadLocalPlugin: 'Upload Local Plugin',
@@ -298,6 +300,7 @@ const enUS = {
defaultBadge: 'Default', defaultBadge: 'Default',
sortBy: 'Sort by', sortBy: 'Sort by',
newestCreated: 'Newest Created', newestCreated: 'Newest Created',
earliestCreated: 'Earliest Created',
recentlyEdited: 'Recently Edited', recentlyEdited: 'Recently Edited',
earliestEdited: 'Earliest Edited', earliestEdited: 'Earliest Edited',
basicInfo: 'Basic', basicInfo: 'Basic',

View File

@@ -207,9 +207,11 @@ const jaJP = {
deleteConfirm: '削除の確認', deleteConfirm: '削除の確認',
deleteSuccess: '削除に成功しました', deleteSuccess: '削除に成功しました',
modifyFailed: '変更に失敗しました:', modifyFailed: '変更に失敗しました:',
eventCount: 'イベント:{{count}}', componentName: {
toolCount: 'ツール:{{count}}', Tool: 'ツール',
starCount: 'スター:{{count}}', EventListener: 'イベント監視器',
Command: 'コマンド',
},
uploadLocal: 'ローカルアップロード', uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中', debugging: 'デバッグ中',
uploadLocalPlugin: 'ローカルプラグインのアップロード', uploadLocalPlugin: 'ローカルプラグインのアップロード',
@@ -300,6 +302,7 @@ const jaJP = {
defaultBadge: 'デフォルト', defaultBadge: 'デフォルト',
sortBy: '並び順', sortBy: '並び順',
newestCreated: '最新作成', newestCreated: '最新作成',
earliestCreated: '最古作成',
recentlyEdited: '最近編集', recentlyEdited: '最近編集',
earliestEdited: '最古編集', earliestEdited: '最古編集',
basicInfo: '基本情報', basicInfo: '基本情報',

View File

@@ -199,9 +199,11 @@ const zhHans = {
deleteConfirm: '删除确认', deleteConfirm: '删除确认',
deleteSuccess: '删除成功', deleteSuccess: '删除成功',
modifyFailed: '修改失败:', modifyFailed: '修改失败:',
eventCount: '事件:{{count}}', componentName: {
toolCount: '工具:{{count}}', Tool: '工具',
starCount: '星标:{{count}}', EventListener: '事件监听器',
Command: '命令',
},
uploadLocal: '本地上传', uploadLocal: '本地上传',
debugging: '调试中', debugging: '调试中',
uploadLocalPlugin: '上传本地插件', uploadLocalPlugin: '上传本地插件',
@@ -285,6 +287,7 @@ const zhHans = {
defaultBadge: '默认', defaultBadge: '默认',
sortBy: '排序方式', sortBy: '排序方式',
newestCreated: '最新创建', newestCreated: '最新创建',
earliestCreated: '最早创建',
recentlyEdited: '最近编辑', recentlyEdited: '最近编辑',
earliestEdited: '最早编辑', earliestEdited: '最早编辑',
basicInfo: '基础信息', basicInfo: '基础信息',

View File

@@ -197,9 +197,11 @@ const zhHant = {
close: '關閉', close: '關閉',
deleteConfirm: '刪除確認', deleteConfirm: '刪除確認',
modifyFailed: '修改失敗:', modifyFailed: '修改失敗:',
eventCount: '事件:{{count}}', componentName: {
toolCount: '工具:{{count}}', Tool: '工具',
starCount: '星標:{{count}}', EventListener: '事件監聽器',
Command: '命令',
},
uploadLocal: '本地上傳', uploadLocal: '本地上傳',
debugging: '調試中', debugging: '調試中',
uploadLocalPlugin: '上傳本地插件', uploadLocalPlugin: '上傳本地插件',
@@ -283,6 +285,7 @@ const zhHant = {
defaultBadge: '預設', defaultBadge: '預設',
sortBy: '排序方式', sortBy: '排序方式',
newestCreated: '最新建立', newestCreated: '最新建立',
earliestCreated: '最早建立',
recentlyEdited: '最近編輯', recentlyEdited: '最近編輯',
earliestEdited: '最早編輯', earliestEdited: '最早編輯',
basicInfo: '基本資訊', basicInfo: '基本資訊',