mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 08:16:03 +00:00
* feat(models): add provider model scanning * fix: double close button * feat: update plugin module * fix(monitoring): WeChat Work feedback recording bugs (#2108) * fix(monitoring): fix WeChat Work feedback recording bugs - Fix feedback events silently dropped when stream session expires: dispatch feedback handlers regardless of session availability - Fix IntegrityError on repeated feedback (like→dislike) for same message: implement UPSERT logic in record_feedback() - Fix cancel feedback (type=3) not removing records: add delete logic - Fix inaccurate_reasons validation error: convert int reason codes to strings before creating FeedbackEvent (Pydantic expects List[str]) - Fix feedback timestamps 8 hours off in frontend: use parseUTCTimestamp instead of new Date() for UTC timestamp parsing - Fix StreamSessionManager.cleanup missing _feedback_index cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(monitoring): apply ruff format to wecom feedback files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: 6mvp6 <13727783693@163.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * feat: add feat for receive files in wecombot * fix: ruff error * fix: always show sidebar plus buttons on touch/mobile devices (#2115) Agent-Logs-Url: https://github.com/langbot-app/LangBot/sessions/e27a4886-fbad-4a7a-8558-67a387852753 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * fix: SPA fallback for all frontend routes, not just /home/* After migrating from Next.js to Vite SPA, routes like /auth/space/callback returned 404 because the static file server only had SPA fallback for /home/*. Now all non-API routes fall back to index.html for React Router to handle. * style: ruff format main.py * feat: add marketplace link when no parser available for file upload Links to /home/market?category=Parser, same pattern as knowledge engine selector. * fix: lint error * fix(user): allow password login and password change for Space accounts with local password set Previously, Space accounts were unconditionally blocked from password login and password change based on account_type. Now the check verifies whether the user actually has a local password set, allowing Space users who have set a local password to authenticate and change it normally. * feat: add edition field to telemetry payload Sends constants.edition (community/saas) with each telemetry event so Space can distinguish between community and SaaS instances. * style: ruff format telemetry.py * fix(dingtalk): use voice recognition text instead of raw audio binary When DingTalk sends a voice message to the bot, the callback JSON contains a 'recognition' field with the speech-to-text result (powered by Qwen). Previously, LangBot only extracted the 'downloadCode' to download the raw audio binary and passed it as 'file_base64' to LLM APIs, which caused 400 errors since most models don't support this content type. This patch: - Extracts the 'recognition' field from DingTalk audio message content - Uses it as plain text input to the LLM instead of raw audio - Falls back to audio binary only when no recognition text is available - Fixes duplicate text issue for audio messages with recognition Fixes voice messages returning 'Request failed' on all LLM models. * feat: integrate Alembic for database migrations Replace manual if-sqlite/if-postgres branching with Alembic: - Add alembic dependency - Create programmatic alembic env (no CLI/alembic.ini needed) - Support async engines via run_sync passthrough - render_as_batch=True for SQLite ALTER TABLE compatibility - Auto-stamp baseline on first run (existing DB at version 25) - Run alembic upgrade head after legacy migrations - Include sample migration showing schema + data migration patterns - Add alembic dir to package-data for distribution * ci: add migration test workflow for SQLite and PostgreSQL Tests alembic upgrade on both databases: - Stamp baseline on existing schema - Upgrade to head - Idempotent re-upgrade - Fresh DB upgrade from scratch * feat: add autogenerate support and CLI entrypoint for alembic - autogenerate: compare ORM models vs DB schema to generate migrations - CLI: python -m langbot.pkg.persistence.alembic_runner <command> - autogenerate, upgrade, stamp, current - Reads data/config.yaml for DB connection * fix: add filereader for dingtalk,lark (#2122) * fix: add filereader for dingtalk * feat: add lark * feat: update uv.lock * chore: update version to 4.9.6 in pyproject.toml, __init__.py, and uv.lock * fix: update langbot-plugin version to 0.3.8 * fix: update langbot-plugin version to 0.3.8 * docs: update database migration instructions in AGENTS.md * fix(dashscopeapi): fix null value check in reasoning content processing logic (#2128) * fix(n8n-runner): fix output_key not applied when n8n returns plain JSON (#2119) * fix: bump dependencies to resolve Dependabot security alerts (#2130) * fix: bump dependencies to resolve Dependabot security alerts Python: - aiohttp: >=3.11.18 → >=3.13.4 (duplicate Host headers, header injection, redirect leak, multipart DoS) - cryptography: >=44.0.3 → >=46.0.7 (buffer overflow with non-contiguous buffers) - pillow: >=11.2.1 → >=12.2.0 (FITS GZIP decompression bomb, HIGH) - langchain-text-splitters: >=0.0.1 → >=1.1.2 (SSRF redirect bypass) - langchain-core: add >=1.2.28 (incomplete f-string validation) - langsmith: add >=0.7.31 (streaming token redaction bypass) - python-multipart: add >=0.0.26 (multipart DoS) - Mako: add >=1.3.11 (path traversal) - pytest: >=8.4.1 → >=9.0.3 (tmpdir handling) - uv: >=0.7.11 → >=0.11.6 (arbitrary file deletion) JavaScript (web/): - vite: ^8.0.3 → ^8.0.5 (fs.deny bypass, WebSocket file read, path traversal, HIGH) - axios: ^1.13.5 → ^1.15.0 (cloud metadata exfiltration) - lodash: ^4.17.23 → ^4.18.0 (code injection via _.template, prototype pollution, HIGH) * fix: update pnpm-lock.yaml for bumped dependencies * feat(ci): add i18n key consistency check for frontend locales (#2133) * feat(ci): add i18n key consistency check workflow Agent-Logs-Url: https://github.com/langbot-app/LangBot/sessions/c7bf50da-189b-49a5-9671-dbe8e70ff9d0 Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * feat(ci): replace eval with line-by-line parser, add permissions block Agent-Logs-Url: https://github.com/langbot-app/LangBot/sessions/c7bf50da-189b-49a5-9671-dbe8e70ff9d0 Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * feat(models): add provider model scanning * feat(models): add 'select all' functionality and enrich model abilities * fix:ruff * fix:ruff --------- Co-authored-by: WangCham <651122857@qq.com> Co-authored-by: 6mvp6 <119733319+6mvp6@users.noreply.github.com> Co-authored-by: 6mvp6 <13727783693@163.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Guanchao Wang <wangcham233@gmail.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> Co-authored-by: RockChinQ <rockchinq@gmail.com> Co-authored-by: haiyangbg <zhouhaiyangaa@gmail.com> Co-authored-by: Rock Chin <1010553892@qq.com> Co-authored-by: Amadeus <115918672+AmadeusKurisu1@users.noreply.github.com> Co-authored-by: hzhhong <hung.z.h916@gmail.com> Co-authored-by: fdc310 <2213070223@qq.com>
315 lines
12 KiB
Python
315 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import typing
|
|
from typing import Union, Mapping, Any, AsyncIterator
|
|
import uuid
|
|
import json
|
|
|
|
import ollama
|
|
import httpx
|
|
|
|
from .. import errors, requester
|
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
|
|
|
REQUESTER_NAME: str = 'ollama-chat'
|
|
|
|
|
|
class OllamaChatCompletions(requester.ProviderAPIRequester):
|
|
"""Ollama平台 ChatCompletion API请求器"""
|
|
|
|
client: ollama.AsyncClient
|
|
|
|
default_config: dict[str, typing.Any] = {
|
|
'base_url': 'http://127.0.0.1:11434',
|
|
'timeout': 120,
|
|
}
|
|
|
|
async def initialize(self):
|
|
os.environ['OLLAMA_HOST'] = self.requester_cfg['base_url']
|
|
self.client = ollama.AsyncClient(timeout=self.requester_cfg['timeout'])
|
|
|
|
def _infer_model_type(self, model_id: str) -> str:
|
|
normalized_model_id = (model_id or '').lower()
|
|
embedding_keywords = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
|
|
return 'embedding' if any(keyword in normalized_model_id for keyword in embedding_keywords) else 'llm'
|
|
|
|
def _infer_model_abilities(self, item: dict[str, typing.Any], model_id: str) -> list[str]:
|
|
normalized_model_id = (model_id or '').lower()
|
|
abilities: set[str] = set()
|
|
details = item.get('details', {}) or {}
|
|
families = details.get('families', []) or []
|
|
tokens = [normalized_model_id, str(details.get('family', '')).lower()]
|
|
tokens.extend(str(family).lower() for family in families)
|
|
|
|
if any(keyword in token for token in tokens for keyword in ('vision', 'vl', 'omni', 'llava', 'ocr')):
|
|
abilities.add('vision')
|
|
if any(keyword in token for token in tokens for keyword in ('tool', 'function')):
|
|
abilities.add('func_call')
|
|
return sorted(abilities)
|
|
|
|
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
|
del api_key
|
|
models_url = f'{self.requester_cfg["base_url"].rstrip("/")}/api/tags'
|
|
|
|
async with httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']) as client:
|
|
response = await client.get(models_url)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
|
|
models: list[dict[str, typing.Any]] = []
|
|
for item in payload.get('models', []):
|
|
model_id = item.get('model') or item.get('name')
|
|
if not model_id:
|
|
continue
|
|
models.append(
|
|
{
|
|
'id': model_id,
|
|
'name': item.get('name', model_id),
|
|
'type': self._infer_model_type(model_id),
|
|
'abilities': self._infer_model_abilities(item, model_id),
|
|
}
|
|
)
|
|
|
|
models.sort(key=lambda item: (item['type'] != 'llm', item['name'].lower()))
|
|
return {
|
|
'models': models,
|
|
'debug': {
|
|
'request': {
|
|
'method': 'GET',
|
|
'url': models_url,
|
|
},
|
|
'response': payload,
|
|
},
|
|
}
|
|
|
|
async def _req(
|
|
self,
|
|
args: dict,
|
|
) -> Union[Mapping[str, Any], AsyncIterator[Mapping[str, Any]]]:
|
|
return await self.client.chat(**args)
|
|
|
|
async def _closure(
|
|
self,
|
|
query: pipeline_query.Query,
|
|
req_messages: list[dict],
|
|
use_model: requester.RuntimeLLMModel,
|
|
use_funcs: list[resource_tool.LLMTool] = None,
|
|
extra_args: dict[str, typing.Any] = {},
|
|
remove_think: bool = False,
|
|
) -> provider_message.Message:
|
|
args = extra_args.copy()
|
|
args['model'] = use_model.model_entity.name
|
|
|
|
messages: list[dict] = req_messages.copy()
|
|
for msg in messages:
|
|
if 'content' in msg and isinstance(msg['content'], list):
|
|
text_content: list = []
|
|
image_urls: list = []
|
|
for me in msg['content']:
|
|
if me['type'] == 'text':
|
|
text_content.append(me['text'])
|
|
elif me['type'] == 'image_base64':
|
|
image_urls.append(me['image_base64'])
|
|
|
|
msg['content'] = '\n'.join(text_content)
|
|
msg['images'] = [url.split(',')[1] for url in image_urls]
|
|
if 'tool_calls' in msg: # LangBot 内部以 str 存储 tool_calls 的参数,这里需要转换为 dict
|
|
for tool_call in msg['tool_calls']:
|
|
tool_call['function']['arguments'] = json.loads(tool_call['function']['arguments'])
|
|
args['messages'] = messages
|
|
|
|
args['tools'] = []
|
|
if use_funcs:
|
|
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
|
|
if tools:
|
|
args['tools'] = tools
|
|
|
|
resp = await self._req(args)
|
|
message: provider_message.Message = await self._make_msg(resp)
|
|
return message
|
|
|
|
async def _make_msg(self, chat_completions: ollama.ChatResponse) -> provider_message.Message:
|
|
message: ollama.Message = chat_completions.message
|
|
if message is None:
|
|
raise ValueError("chat_completions must contain a 'message' field")
|
|
|
|
ret_msg: provider_message.Message = None
|
|
|
|
if message.content is not None:
|
|
ret_msg = provider_message.Message(role='assistant', content=message.content)
|
|
if message.tool_calls is not None and len(message.tool_calls) > 0:
|
|
tool_calls: list[provider_message.ToolCall] = []
|
|
|
|
for tool_call in message.tool_calls:
|
|
tool_calls.append(
|
|
provider_message.ToolCall(
|
|
id=uuid.uuid4().hex,
|
|
type='function',
|
|
function=provider_message.FunctionCall(
|
|
name=tool_call.function.name,
|
|
arguments=json.dumps(tool_call.function.arguments),
|
|
),
|
|
)
|
|
)
|
|
ret_msg.tool_calls = tool_calls
|
|
|
|
return ret_msg
|
|
|
|
async def _prepare_messages(
|
|
self,
|
|
messages: typing.List[provider_message.Message],
|
|
) -> list[dict]:
|
|
"""Prepare messages for Ollama API request."""
|
|
req_messages: list = []
|
|
for m in messages:
|
|
msg_dict: dict = m.dict(exclude_none=True)
|
|
content: Any = msg_dict.get('content')
|
|
if isinstance(content, list):
|
|
if all(isinstance(part, dict) and part.get('type') == 'text' for part in content):
|
|
msg_dict['content'] = '\n'.join(part['text'] for part in content)
|
|
req_messages.append(msg_dict)
|
|
return req_messages
|
|
|
|
async def invoke_llm(
|
|
self,
|
|
query: pipeline_query.Query,
|
|
model: requester.RuntimeLLMModel,
|
|
messages: typing.List[provider_message.Message],
|
|
funcs: typing.List[resource_tool.LLMTool] = None,
|
|
extra_args: dict[str, typing.Any] = {},
|
|
remove_think: bool = False,
|
|
) -> provider_message.Message:
|
|
req_messages = await self._prepare_messages(messages)
|
|
try:
|
|
return await self._closure(
|
|
query=query,
|
|
req_messages=req_messages,
|
|
use_model=model,
|
|
use_funcs=funcs,
|
|
extra_args=extra_args,
|
|
remove_think=remove_think,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
raise errors.RequesterError('请求超时')
|
|
|
|
async def invoke_llm_stream(
|
|
self,
|
|
query: pipeline_query.Query,
|
|
model: requester.RuntimeLLMModel,
|
|
messages: typing.List[provider_message.Message],
|
|
funcs: typing.List[resource_tool.LLMTool] = None,
|
|
extra_args: dict[str, typing.Any] = {},
|
|
remove_think: bool = False,
|
|
) -> provider_message.MessageChunk:
|
|
req_messages = await self._prepare_messages(messages)
|
|
|
|
try:
|
|
args = extra_args.copy()
|
|
args['model'] = model.model_entity.name
|
|
|
|
# Process messages for Ollama format
|
|
msgs: list[dict] = req_messages.copy()
|
|
for msg in msgs:
|
|
if 'content' in msg and isinstance(msg['content'], list):
|
|
text_content: list = []
|
|
image_urls: list = []
|
|
for me in msg['content']:
|
|
if me['type'] == 'text':
|
|
text_content.append(me['text'])
|
|
elif me['type'] == 'image_base64':
|
|
image_urls.append(me['image_base64'])
|
|
msg['content'] = '\n'.join(text_content)
|
|
msg['images'] = [url.split(',')[1] for url in image_urls]
|
|
if 'tool_calls' in msg:
|
|
for tool_call in msg['tool_calls']:
|
|
tool_call['function']['arguments'] = json.loads(tool_call['function']['arguments'])
|
|
args['messages'] = msgs
|
|
|
|
args['tools'] = []
|
|
if funcs:
|
|
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
|
|
if tools:
|
|
args['tools'] = tools
|
|
|
|
args['stream'] = True
|
|
|
|
chunk_idx = 0
|
|
thinking_started = False
|
|
thinking_ended = False
|
|
role = 'assistant'
|
|
|
|
async for chunk in await self.client.chat(**args):
|
|
message: ollama.Message = chunk.message
|
|
done = chunk.done
|
|
|
|
delta_content = message.content or ''
|
|
reasoning_content = getattr(message, 'thinking', '') or ''
|
|
|
|
# Handle reasoning/thinking content
|
|
if reasoning_content:
|
|
if remove_think:
|
|
chunk_idx += 1
|
|
continue
|
|
|
|
if not thinking_started:
|
|
thinking_started = True
|
|
delta_content = '<think>\n' + reasoning_content
|
|
else:
|
|
delta_content = reasoning_content
|
|
elif thinking_started and not thinking_ended and delta_content:
|
|
thinking_ended = True
|
|
delta_content = '\n</think>\n' + delta_content
|
|
|
|
# Handle tool calls
|
|
tool_calls_data = None
|
|
if message.tool_calls:
|
|
tool_calls_data = []
|
|
for tc in message.tool_calls:
|
|
tool_calls_data.append(
|
|
{
|
|
'id': uuid.uuid4().hex,
|
|
'type': 'function',
|
|
'function': {
|
|
'name': tc.function.name,
|
|
'arguments': json.dumps(tc.function.arguments),
|
|
},
|
|
}
|
|
)
|
|
|
|
# Skip empty first chunk
|
|
if chunk_idx == 0 and not delta_content and not reasoning_content and not tool_calls_data:
|
|
chunk_idx += 1
|
|
continue
|
|
|
|
chunk_data = {
|
|
'role': role,
|
|
'content': delta_content if delta_content else None,
|
|
'tool_calls': tool_calls_data,
|
|
'is_final': bool(done),
|
|
}
|
|
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
|
|
|
yield provider_message.MessageChunk(**chunk_data)
|
|
chunk_idx += 1
|
|
|
|
except asyncio.TimeoutError:
|
|
raise errors.RequesterError('请求超时')
|
|
|
|
async def invoke_embedding(
|
|
self,
|
|
model: requester.RuntimeEmbeddingModel,
|
|
input_text: list[str],
|
|
extra_args: dict[str, typing.Any] = {},
|
|
) -> list[list[float]]:
|
|
return (
|
|
await self.client.embed(
|
|
model=model.model_entity.name,
|
|
input=input_text,
|
|
**extra_args,
|
|
)
|
|
).embeddings
|