Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/admin/public/provider/kimi-color.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions backend/admin/src/app/admin/llm/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const PROVIDER_ICONS: Record<string, string> = {
sora: withBasePath('/provider/sora-color.svg'),
ark: withBasePath('/provider/volcengine-color.svg'),
ollama: withBasePath('/provider/ollama.svg'),
kimi: withBasePath('/provider/kimi-color.svg'),
};

// Provider brand labels come from the vendor itself (proper nouns), not translated.
Expand All @@ -60,6 +61,7 @@ export const PROVIDER_OPTIONS = [
{ value: 'deepseek', label: 'DeepSeek', icon: PROVIDER_ICONS.deepseek },
{ value: 'minimax', label: 'MiniMax', icon: PROVIDER_ICONS.minimax },
{ value: 'xai', label: 'xAI (Grok)', icon: PROVIDER_ICONS.xai },
{ value: 'kimi', label: 'Kimi (Moonshot)', icon: PROVIDER_ICONS.kimi },
{ value: 'ark', label: '火山方舟 (Ark)', icon: PROVIDER_ICONS.ark },
{ value: 'openrouter', label: 'OpenRouter', icon: PROVIDER_ICONS.openrouter },
{ value: 'ollama', label: 'Ollama (Local)', icon: PROVIDER_ICONS.ollama },
Expand Down
6 changes: 6 additions & 0 deletions backend/scripts/seed_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ def run_migrations():
"models": ["deepseek-chat", "deepseek-reasoner"],
"tags": ["llm"],
},
{
"name": "Kimi",
"provider_type": "kimi",
"models": ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"],
"tags": ["llm"],
},
{
# Ollama 本地部署:api_key 留空;models 留空交由后台「同步本地模型」按钮拉取
# base_url 默认 localhost,若部署在 Docker 中需手动改为 http://host.docker.internal:11434
Expand Down
14 changes: 10 additions & 4 deletions backend/services/agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,20 @@ def _block_text(block) -> str:

def _extract_tool_results(new_messages: list, is_anthropic: bool) -> dict:
"""Extract tool results from messages appended by append_tool_round_with_errors.
Returns {tool_call_id: result_content} mapping.
Returns {tool_call_id: result_content_str} mapping.
Multimodal list content is flattened to text for SSE transport.
"""
results = {}
for msg in new_messages:
# OpenAI format: {"role": "tool", "tool_call_id": ..., "content": ...}
(not is_anthropic and msg.get("role") == "tool") and results.__setitem__(
msg.get("tool_call_id", ""), msg.get("content", "")
)
if not is_anthropic and msg.get("role") == "tool":
content = msg.get("content", "")
# 多模态 content (list) → 提取 text 部分作为摘要
text_val = (
next((p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"), "[multimodal content]")
if isinstance(content, list) else content
)
results[msg.get("tool_call_id", "")] = text_val
# Anthropic format: {"role": "user", "content": [{"type": "tool_result", ...}]}
is_anthropic and msg.get("role") == "user" and isinstance(msg.get("content"), list) and [
results.__setitem__(block.get("tool_use_id", ""), block.get("content", ""))
Expand Down
29 changes: 22 additions & 7 deletions backend/services/chat_tool_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@

async def get_tool_result(
tool_manager: "ToolManager", tc_name: str, tc_args: dict, ctx: "ToolContext",
) -> str:
"""Dispatch tool execution with timing and logging."""
) -> str | list:
"""Dispatch tool execution with timing and logging.

Returns str (text result) or list (multimodal content array for tools like view_node_media).
"""
# 容错:LLM 可能发送 "skill_name:tool_name" 格式(如 "video_tools:generate_video"),自动提取真实工具名
tc_name = tc_name.rsplit(":", 1)[-1] if ":" in tc_name else tc_name
start = time.perf_counter()
Expand Down Expand Up @@ -131,7 +134,8 @@ async def _execute_valid_calls_parallel(
if len(valid_calls) == 1:
tc, args = valid_calls[0]
content = await get_tool_result(tool_manager, tc.name, args, ctx)
logger.info(f" {tc.name}({args}) → {len(content)} chars")
_log_len = len(content) if isinstance(content, str) else f"multimodal({len(content)} parts)"
logger.info(f" {tc.name}({args}) → {_log_len}")
return [(tc.id, content)]

# Split into sequential (canvas mutations) and parallel (everything else)
Expand All @@ -145,7 +149,8 @@ async def _run(tc, args):
async with AsyncSessionLocal() as session:
isolated_ctx = replace(ctx, db=session)
content = await get_tool_result(tool_manager, tc.name, args, isolated_ctx)
logger.info(f" {tc.name}({args}) → {len(content)} chars")
_log_len = len(content) if isinstance(content, str) else f"multimodal({len(content)} parts)"
logger.info(f" {tc.name}({args}) → {_log_len}")
return tc.id, content

# Run canvas mutations sequentially (each needs to see prior commits)
Expand All @@ -155,7 +160,8 @@ async def _run_sequential():
async with AsyncSessionLocal() as session:
isolated_ctx = replace(ctx, db=session)
content = await get_tool_result(tool_manager, tc.name, args, isolated_ctx)
logger.info(f" {tc.name}({args}) → {len(content)} chars")
_log_len = len(content) if isinstance(content, str) else f"multimodal({len(content)} parts)"
logger.info(f" {tc.name}({args}) → {_log_len}")
seq_results.append((tc.id, content))
return seq_results

Expand All @@ -180,6 +186,15 @@ async def _run_sequential():
return results


def _flatten_content_for_anthropic(content: str | list) -> str:
"""Anthropic tool_result content 仅支持 string,多模态 list 降级为纯文本。"""
if isinstance(content, str):
return content
# 从多模态 content parts 中提取所有 text 部分
text_parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
return "\n".join(text_parts) or "[Media content — view in multimodal-capable provider]"


async def _append_anthropic_tool_round(
messages: list, result, tool_manager: "ToolManager", ctx: "ToolContext",
):
Expand All @@ -197,7 +212,7 @@ async def _append_anthropic_tool_round(
[(tc, json.loads(tc.arguments)) for tc in result.tool_calls], tool_manager, ctx,
)
tool_results = [
{"type": "tool_result", "tool_use_id": tc_id, "content": content}
{"type": "tool_result", "tool_use_id": tc_id, "content": _flatten_content_for_anthropic(content)}
for tc_id, content in results
]
messages.append({"role": "user", "content": tool_results})
Expand Down Expand Up @@ -231,7 +246,7 @@ async def _append_anthropic_tool_round_with_errors(
results_map = dict(results)

tool_results = [
{"type": "tool_result", "tool_use_id": tc.id, "content": results_map[tc.id]}
{"type": "tool_result", "tool_use_id": tc.id, "content": _flatten_content_for_anthropic(results_map[tc.id])}
for tc, _ in valid_calls
]
# 处理错误调用 - 直接返回错误信息
Expand Down
167 changes: 167 additions & 0 deletions backend/services/llm_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class StreamResult:
"ark": "https://ark.cn-beijing.volces.com/api/v3",
"doubao": "https://ark.cn-beijing.volces.com/api/v3",
"openrouter": "https://openrouter.ai/api/v1",
"kimi": "https://api.moonshot.ai/v1",
}

# 供应商注册表:provider_type -> stream handler
Expand Down Expand Up @@ -265,6 +266,172 @@ async def stream_ollama(ctx: StreamContext, result: StreamResult) -> AsyncGenera
async for chunk in _PROVIDER_REGISTRY["openai"](patched, result):
yield chunk


# ============================================================
# Kimi (Moonshot AI) 供应商 — OpenAI 兼容 + thinking/reasoning_effort
# ============================================================


def _convert_inline_data_for_kimi(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""将 inline_data parts 转换为 Kimi 兼容格式。

Kimi 多模态支持:
- image_url → 直接透传(Kimi 原生支持 OpenAI image_url 格式)
- video inline_data → {"type": "video_url", "video_url": {"url": "data:video/mp4;base64,..."}}
- image inline_data → {"type": "image_url", "image_url": {"url": "data:image/...;base64,..."}}
- audio inline_data → 文本占位符(Kimi 不支持音频输入)
"""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_parts = []
for part in content:
if not isinstance(part, dict) or part.get("type") != "inline_data":
new_parts.append(part)
continue
inline = part.get("inline_data", {})
mime = inline.get("mime_type", "")
data = inline.get("data", "")
# 视频 → Kimi video_url 格式
if mime.startswith("video/"):
new_parts.append({"type": "video_url", "video_url": {"url": f"data:{mime};base64,{data}"}})
# 图片 → 标准 image_url 格式
elif mime.startswith("image/"):
new_parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{data}"}})
# 音频 → Kimi 不支持音频输入,转为文本提示
elif mime.startswith("audio/"):
new_parts.append({"type": "text", "text": "[Audio content — not supported by this model]"})
else:
new_parts.append({"type": "text", "text": f"[Unsupported media: {mime}]"})
converted.append({**msg, "content": new_parts})
return converted

# Kimi 模型关键词 → thinking 模式 extra_body 映射
# K3: 使用顶层 reasoning_effort; K2.7-code: thinking 始终开启无需传参;
# K2.6/K2.5: 通过 thinking 参数控制
_KIMI_MODEL_PATTERNS = {
"k3": "reasoning_effort", # kimi-k3 → reasoning_effort: "max"
"k2.7": "thinking_always", # kimi-k2.7-code → 始终 thinking,无需额外参数
"k2.6": "thinking_toggle", # kimi-k2.6 → thinking 可控
"k2.5": "thinking_toggle", # kimi-k2.5 → thinking 可控
}


def _resolve_kimi_thinking_mode(model: str) -> str:
"""根据模型名称确定 Kimi 的 thinking 控制策略。"""
model_lower = model.lower()
return next(
(mode for pattern, mode in _KIMI_MODEL_PATTERNS.items() if pattern in model_lower),
"none", # moonshot-v1 等旧模型无 thinking 支持
)


@register_provider("kimi")
async def stream_kimi(ctx: StreamContext, result: StreamResult) -> AsyncGenerator[str, None]:
"""Kimi (Moonshot AI) 流式调用

特性:
- OpenAI 兼容 API(base_url=https://api.moonshot.ai/v1)
- K3: reasoning_effort="max"(顶层参数),始终推理
- K2.7-code: thinking 始终开启,无需传参
- K2.6/K2.5: thinking 通过 extra_body 控制
- 所有 K2.6+ 模型 temperature 固定,不可传递
"""
client = _get_openai_client(ctx.api_key, get_effective_base_url(ctx))

# 将 inline_data parts 转为 Kimi 兼容格式(video→video_url, image→image_url, audio→不支持)
effective_messages = _convert_inline_data_for_kimi(ctx.messages)

create_params = {
"model": ctx.model,
"messages": effective_messages,
"stream": True,
"stream_options": {"include_usage": True},
}
# 注意:不传递 temperature(Kimi K2.6+ 模型 temperature 固定,传递会报错)

ctx.tools and create_params.update(tools=ctx.tools)

# Thinking 模式处理(数据驱动,避免 if 分支)
thinking_mode_type = _resolve_kimi_thinking_mode(ctx.model)
extra_body: Dict[str, Any] = {}

# K3: reasoning_effort 顶层参数
(thinking_mode_type == "reasoning_effort") and extra_body.update(reasoning_effort="max")

# K2.6/K2.5: thinking 参数可控
(thinking_mode_type == "thinking_toggle" and ctx.thinking_mode) and extra_body.update(
thinking={"type": "enabled", "keep": "all"}
)
(thinking_mode_type == "thinking_toggle" and not ctx.thinking_mode) and extra_body.update(
thinking={"type": "disabled"}
)

# K2.7-code: thinking 始终开启,无需额外参数(模型默认行为)

extra_body and create_params.update(extra_body=extra_body)

logger.info(
f"Kimi stream: model={ctx.model}, thinking_type={thinking_mode_type}, "
f"extra_body={extra_body or 'none'}"
)

stream = await client.chat.completions.create(**create_params)

thinking_started = False
pending_tool_calls: dict[int, dict] = {}

async for chunk in stream:
# 处理 reasoning_content (thinking mode)
if ctx.thinking_mode and chunk.choices and hasattr(chunk.choices[0].delta, 'reasoning_content'):
rc = chunk.choices[0].delta.reasoning_content
if rc:
if not thinking_started:
yield "<think>"
thinking_started = True
result.reasoning_content += rc
yield rc

if chunk.choices and chunk.choices[0].delta.content:
if thinking_started:
yield "</think>"
thinking_started = False
content = chunk.choices[0].delta.content
result.full_response += content
yield content

# Collect tool_calls from delta
if chunk.choices and hasattr(chunk.choices[0].delta, 'tool_calls') and chunk.choices[0].delta.tool_calls:
for tc_delta in chunk.choices[0].delta.tool_calls:
idx = tc_delta.index
entry = pending_tool_calls.setdefault(idx, {"id": "", "name": "", "arguments": ""})
tc_delta.id and entry.update(id=tc_delta.id)
_new_name = tc_delta.function and tc_delta.function.name
_new_name and not entry["name"] and (yield f"__TOOL_PENDING__:{_new_name}")
_new_name and entry.update(name=_new_name)
_arg_chunk = tc_delta.function and tc_delta.function.arguments
_arg_chunk and entry.update(arguments=entry["arguments"] + _arg_chunk)
(_arg_chunk and entry["name"]) and (yield f"__TOOL_DELTA__:{entry['name']}:{_arg_chunk}")

if hasattr(chunk, 'usage') and chunk.usage:
result.input_tokens = chunk.usage.prompt_tokens
result.output_tokens = chunk.usage.completion_tokens

if thinking_started:
yield "</think>"

# Convert collected tool calls to result
if pending_tool_calls:
result.tool_calls = [
ToolCallResult(id=tc["id"], name=tc["name"], arguments=tc["arguments"])
for tc in pending_tool_calls.values()
if tc["name"]
]


# xAI 推理参数配置(模型关键词 → thinking_mode 开启时的 extra_body)
# grok-3-mini: 支持 reasoning_effort,Chat Completions 返回 reasoning_content
# grok-4/grok-4-fast-reasoning/grok-4.1+: 不支持 reasoning_effort(传递会 API 报错),
Expand Down
9 changes: 7 additions & 2 deletions backend/services/tool_execution_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,17 @@ async def _write_record(
def record_tool_execution(
tool_name: str,
args: dict,
result: str,
result: str | list,
status: str,
duration_ms: int,
ctx: "ToolContext",
) -> None:
"""Schedule a non-blocking write of a tool execution record."""
# 多模态 tool result (list) 序列化为文本摘要,避免 SQLite 绑定错误
result_str = (
next((p.get("text", "") for p in result if isinstance(p, dict) and p.get("type") == "text"), "[multimodal content]")
if isinstance(result, list) else result
)
asyncio.create_task(
_write_record(tool_name, args, result, status, duration_ms, ctx)
_write_record(tool_name, args, result_str, status, duration_ms, ctx)
)
Loading