mirror of
https://github.com/val1813/kwcode.git
synced 2026-09-03 06:34:30 +08:00
feat: add multimodal vision expert for image analysis and code generation
- VisionExpert class: image analysis + code generation from images - Anthropic Messages API integration (mimo-v2-omni model) - Gate classifier: new 'vision' expert type - Orchestrator: vision pipeline with image_paths support - CLI: /paste (clipboard) and /image (file) commands - Optional deps: pip install kwcode[multimodal] (Pillow + pyperclip) - Architecture diagram in docs/
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ build/
|
||||
.pytest_cache/
|
||||
test_project/
|
||||
.eggs/
|
||||
.DS_Store
|
||||
|
||||
13
README.md
13
README.md
@@ -211,6 +211,14 @@ Prompt Optimizer(可选,需 Anthropic API key):
|
||||
### Office 文档
|
||||
- Excel / PPT / Word 生成
|
||||
|
||||
### 多模态图片处理
|
||||
- `/paste` 命令:从剪贴板粘贴图片
|
||||
- `/image <path>` 命令:添加图片文件
|
||||
- 图片分析:代码截图、UI设计图、文档表格
|
||||
- 基于图片的代码生成:UI截图→HTML/CSS、错误截图→修复代码
|
||||
- 支持格式:PNG、JPG、JPEG、GIF、WebP、BMP
|
||||
- 安装:`pip install kwcode[multimodal]`
|
||||
|
||||
### 价值可见
|
||||
- `kwcode stats`:完成任务数、节省时间估算
|
||||
- 飞轮通知:专家投产时弹出
|
||||
@@ -355,6 +363,8 @@ kwcode --plan "重构数据库连接层"
|
||||
/memory 查看项目记忆
|
||||
/init 初始化项目规则文件
|
||||
/cd <路径> 切换项目目录
|
||||
/paste 从剪贴板粘贴图片
|
||||
/image <路径> 添加图片文件
|
||||
/help 显示帮助
|
||||
```
|
||||
|
||||
@@ -418,7 +428,8 @@ kaiwu/
|
||||
│ ├── verifier.py # [元专家] 语法检查 + pytest
|
||||
│ ├── debug_subagent.py # [元专家] 运行时调试(sys.settrace)
|
||||
│ ├── reviewer.py # [元专家] 需求对齐审查
|
||||
│ └── search_augmentor.py # 搜索增强 + BM25 + CE 重排
|
||||
│ ├── search_augmentor.py # 搜索增强 + BM25 + CE 重排
|
||||
│ └── vision_expert.py # [元专家] 多模态图片处理
|
||||
├── search/
|
||||
│ ├── reranker.py # Cross-Encoder 可选重排
|
||||
│ ├── duckduckgo.py # SearXNG + DDG 并行搜索
|
||||
|
||||
BIN
docs/kwcode_architecture.png
Normal file
BIN
docs/kwcode_architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -161,6 +161,10 @@ def _build_pipeline(model_path, ollama_url, ollama_model, project_root, verbose)
|
||||
from kaiwu.experts.chat_expert import ChatExpert
|
||||
chat_expert = ChatExpert(llm=llm, search_augmentor=search)
|
||||
|
||||
# Vision Expert (多模态图片处理)
|
||||
from kaiwu.experts.vision_expert import VisionExpert
|
||||
vision_expert = VisionExpert(llm=llm, tool_executor=tools)
|
||||
|
||||
# Debug Subagent (问题1修复:实例化并注入)
|
||||
from kaiwu.experts.debug_subagent import DebugSubagent
|
||||
debug_subagent = DebugSubagent(llm, tools)
|
||||
@@ -183,6 +187,7 @@ def _build_pipeline(model_path, ollama_url, ollama_model, project_root, verbose)
|
||||
ab_tester=ab_tester,
|
||||
chat_expert=chat_expert,
|
||||
debug_subagent=debug_subagent,
|
||||
vision_expert=vision_expert,
|
||||
)
|
||||
# Wire circular reference: ABTester needs orchestrator for backtest
|
||||
ab_tester.orchestrator = orchestrator
|
||||
@@ -192,17 +197,26 @@ def _build_pipeline(model_path, ollama_url, ollama_model, project_root, verbose)
|
||||
|
||||
# ── Single task execution ─────────────────────────────────────
|
||||
|
||||
def _run_task(task, gate, orchestrator, memory, project_root, verbose, plan=False, no_search=False):
|
||||
def _run_task(task, gate, orchestrator, memory, project_root, verbose, plan=False, no_search=False, image_paths=None):
|
||||
"""Execute a single task through the pipeline. Returns success bool."""
|
||||
from kaiwu.core.orchestrator import EXPERT_SEQUENCES
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
# 处理图片上下文
|
||||
if image_paths:
|
||||
logger.info(f"[main] 任务包含 {len(image_paths)} 张图片")
|
||||
# 将图片路径添加到任务描述中
|
||||
image_context = "\n".join([f"[图片: {img}]" for img in image_paths])
|
||||
task_with_images = f"{task}\n\n{image_context}"
|
||||
else:
|
||||
task_with_images = task
|
||||
|
||||
# Gate (with spinner)
|
||||
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
||||
transient=True, console=console) as progress:
|
||||
spin = progress.add_task("分析任务...", total=None)
|
||||
try:
|
||||
gate_result = gate.classify(task, memory_context=memory.load(project_root))
|
||||
gate_result = gate.classify(task_with_images, memory_context=memory.load(project_root))
|
||||
except Exception as e:
|
||||
progress.stop()
|
||||
console.print(f"\n [red]模型调用失败:{e}[/red]")
|
||||
@@ -298,12 +312,13 @@ def _run_task(task, gate, orchestrator, memory, project_root, verbose, plan=Fals
|
||||
logger.debug("[main] 预搜索失败: %s", e)
|
||||
|
||||
result = orchestrator.run(
|
||||
user_input=task,
|
||||
user_input=task_with_images,
|
||||
gate_result=gate_result,
|
||||
project_root=project_root,
|
||||
on_status=_status_fn,
|
||||
no_search=no_search,
|
||||
pre_search_results=pre_search,
|
||||
image_paths=image_paths,
|
||||
)
|
||||
# 保存最后结果供conversation_history使用(问题7)
|
||||
orchestrator._last_result = result
|
||||
@@ -393,6 +408,8 @@ REPL_COMMANDS = {
|
||||
"/plan": "计划模式 (用法: /plan <任务> 或 /plan 后输入任务)",
|
||||
"/multi": "多任务模式 (用法: /multi 后按提示输入多个任务)",
|
||||
"/api": "API配置 (用法: /api show | /api temp <url> | /api default <url>)",
|
||||
"/paste": "从剪贴板粘贴图片",
|
||||
"/image": "添加图片文件 (用法: /image <path>)",
|
||||
"/exit": "退出",
|
||||
}
|
||||
|
||||
@@ -642,6 +659,35 @@ def _repl(model_path, ollama_url, ollama_model, project_root, verbose):
|
||||
elif cmd == "/multi":
|
||||
_handle_multi_command(arg, gate, orchestrator, project_root, console)
|
||||
|
||||
elif cmd == "/paste":
|
||||
from kaiwu.experts.vision_expert import save_clipboard_image
|
||||
image_path = save_clipboard_image()
|
||||
if image_path:
|
||||
console.print(f" [green]图片已从剪贴板保存: {image_path}[/green]")
|
||||
console.print(" [dim]现在可以输入任务描述,图片将作为上下文[/dim]")
|
||||
# 存储图片路径供后续任务使用
|
||||
if not hasattr(session, '_pending_images'):
|
||||
session._pending_images = []
|
||||
session._pending_images.append(image_path)
|
||||
else:
|
||||
console.print(" [yellow]剪贴板中没有图片[/yellow]")
|
||||
|
||||
elif cmd == "/image":
|
||||
if not arg:
|
||||
console.print(" [yellow]用法: /image <图片路径>[/yellow]")
|
||||
else:
|
||||
from kaiwu.experts.vision_expert import validate_image_path
|
||||
image_path = arg.strip()
|
||||
if validate_image_path(image_path):
|
||||
console.print(f" [green]图片已添加: {image_path}[/green]")
|
||||
console.print(" [dim]现在可以输入任务描述,图片将作为上下文[/dim]")
|
||||
# 存储图片路径供后续任务使用
|
||||
if not hasattr(session, '_pending_images'):
|
||||
session._pending_images = []
|
||||
session._pending_images.append(image_path)
|
||||
else:
|
||||
console.print(f" [red]图片文件不存在或格式不支持: {image_path}[/red]")
|
||||
|
||||
elif cmd == "/api":
|
||||
api_parts = user_input.split()
|
||||
result = _handle_api_command(api_parts, ollama_url, ollama_model)
|
||||
@@ -677,6 +723,13 @@ def _repl(model_path, ollama_url, ollama_model, project_root, verbose):
|
||||
f"耗时{pruner._last_compress_ms:.1f}ms)[/dim]"
|
||||
)
|
||||
|
||||
# 处理待处理的图片
|
||||
pending_images = getattr(session, '_pending_images', [])
|
||||
if pending_images:
|
||||
console.print(f" [cyan]图片上下文: {len(pending_images)} 张图片[/cyan]")
|
||||
for img_path in pending_images:
|
||||
console.print(f" - {img_path}")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
# P2: Small model forces plan mode (问题6修复:用户可通过 no_search 间接控制)
|
||||
effective_plan = plan_next or (model_strategy.force_plan_mode and not no_search)
|
||||
@@ -689,7 +742,13 @@ def _repl(model_path, ollama_url, ollama_model, project_root, verbose):
|
||||
verbose=verbose,
|
||||
plan=effective_plan,
|
||||
no_search=False,
|
||||
image_paths=pending_images if pending_images else None,
|
||||
)
|
||||
|
||||
# 清除已使用的图片
|
||||
if pending_images:
|
||||
session._pending_images = []
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
plan_next = False # Reset plan flag
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ expert_type选项:
|
||||
- doc:写注释、文档、README(仅限代码相关文档,用户明确提到已有文件+docstring/注释)
|
||||
- office:仅限生成Excel(.xlsx)/Word(.docx)/PPT(.pptx)办公文档,不包括代码文件
|
||||
- chat:问候、闲聊、非编码问题、询问天气、询问知识
|
||||
- vision:图片分析、图片代码生成、UI截图分析、设计图实现(用户上传了图片或提到图片相关内容)
|
||||
|
||||
difficulty选项:easy | hard
|
||||
hard条件(满足任意一条):
|
||||
@@ -48,6 +49,7 @@ subtask_hint:仅difficulty=hard时填写,逗号分隔的子任务简述。
|
||||
- "修复src/xxx.py" → locator_repair
|
||||
- "重构src/xxx.py" → refactor
|
||||
- 不确定时优先选codegen或locator_repair,不要选office
|
||||
- 图片相关任务(上传图片、分析截图、根据设计图生成代码) → vision
|
||||
|
||||
示例:
|
||||
- "你好" → {{"expert_type":"chat","task_summary":"问候","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
@@ -57,6 +59,8 @@ subtask_hint:仅difficulty=hard时填写,逗号分隔的子任务简述。
|
||||
- "生成一个Excel报表" → {{"expert_type":"office","task_summary":"Excel报表","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
- "根据内容生成项目介绍PPT" → {{"expert_type":"office","task_summary":"项目PPT","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
- "做个幻灯片汇报" → {{"expert_type":"office","task_summary":"汇报PPT","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
- "分析这张截图" → {{"expert_type":"vision","task_summary":"图片分析","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
- "根据UI设计图生成HTML代码" → {{"expert_type":"vision","task_summary":"UI代码生成","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
|
||||
格式:{{"expert_type":"...","task_summary":"10字内","difficulty":"...","needs_search":false,"subtask_hint":""}}
|
||||
|
||||
@@ -66,14 +70,14 @@ subtask_hint:仅difficulty=hard时填写,逗号分隔的子任务简述。
|
||||
GATE_GRAMMAR = r'''
|
||||
root ::= "{" ws expert-type "," ws task-summary "," ws difficulty "}" ws
|
||||
expert-type ::= "\"expert_type\"" ws ":" ws "\"" expert-val "\""
|
||||
expert-val ::= "locator_repair" | "codegen" | "refactor" | "doc" | "office" | "chat"
|
||||
expert-val ::= "locator_repair" | "codegen" | "refactor" | "doc" | "office" | "chat" | "vision"
|
||||
task-summary ::= "\"task_summary\"" ws ":" ws string
|
||||
difficulty ::= "\"difficulty\"" ws ":" ws ("\"easy\"" | "\"hard\"")
|
||||
string ::= "\"" [^"]* "\""
|
||||
ws ::= [ \t\n]*
|
||||
'''
|
||||
|
||||
VALID_EXPERT_TYPES = {"locator_repair", "codegen", "refactor", "doc", "office", "chat"}
|
||||
VALID_EXPERT_TYPES = {"locator_repair", "codegen", "refactor", "doc", "office", "chat", "vision"}
|
||||
VALID_DIFFICULTIES = {"easy", "hard"}
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ EXPERT_SEQUENCES = {
|
||||
"doc": ["locator", "generator"],
|
||||
"office": ["office"],
|
||||
"chat": ["chat"],
|
||||
"vision": ["vision"],
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +60,7 @@ class PipelineOrchestrator:
|
||||
ab_tester: ABTester | None = None,
|
||||
chat_expert: ChatExpert | None = None,
|
||||
debug_subagent=None,
|
||||
vision_expert=None,
|
||||
):
|
||||
self.locator = locator
|
||||
self.generator = generator
|
||||
@@ -66,6 +68,7 @@ class PipelineOrchestrator:
|
||||
self.search_augmentor = search_augmentor
|
||||
self.office_handler = office_handler
|
||||
self.chat_expert = chat_expert
|
||||
self.vision_expert = vision_expert
|
||||
self.tools = tool_executor
|
||||
self.memory = memory
|
||||
self.registry = registry
|
||||
@@ -85,6 +88,7 @@ class PipelineOrchestrator:
|
||||
no_search: bool = False,
|
||||
skip_checkpoint: bool = False,
|
||||
pre_search_results: str = "",
|
||||
image_paths: list = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Execute the expert pipeline.
|
||||
@@ -104,6 +108,11 @@ class PipelineOrchestrator:
|
||||
expert_system_prompt=gate_result.get("system_prompt", ""),
|
||||
)
|
||||
|
||||
# 处理图片路径
|
||||
if image_paths:
|
||||
ctx.image_paths = image_paths
|
||||
logger.info(f"[orchestrator] 任务包含 {len(image_paths)} 张图片")
|
||||
|
||||
expert_type = gate_result.get("expert_type", "locator_repair")
|
||||
|
||||
# ── Pre-search results injection (P1-B) ──
|
||||
@@ -140,6 +149,30 @@ class PipelineOrchestrator:
|
||||
"elapsed": elapsed,
|
||||
}
|
||||
|
||||
# vision类型:图片处理任务
|
||||
if expert_type == "vision":
|
||||
self._emit(on_status, "vision", "图片处理模式")
|
||||
if self.vision_expert and ctx.image_paths:
|
||||
# 使用第一个图片进行处理
|
||||
ctx.image_path = ctx.image_paths[0]
|
||||
result = self.vision_expert.run(ctx)
|
||||
elapsed = time.time() - start_time
|
||||
return {
|
||||
"success": result.get("success", False),
|
||||
"context": ctx,
|
||||
"error": result.get("error"),
|
||||
"elapsed": elapsed,
|
||||
}
|
||||
else:
|
||||
ctx.generator_output = {"explanation": "图片处理功能需要配置Vision专家", "patches": []}
|
||||
elapsed = time.time() - start_time
|
||||
return {
|
||||
"success": False,
|
||||
"context": ctx,
|
||||
"error": "Vision专家未配置或未提供图片",
|
||||
"elapsed": elapsed,
|
||||
}
|
||||
|
||||
# Gate 3: AB test — check if a candidate expert should be used for this task
|
||||
ab_candidate_name = None
|
||||
ab_used_new = False
|
||||
|
||||
425
kaiwu/experts/vision_expert.py
Normal file
425
kaiwu/experts/vision_expert.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
VisionExpert: 多模态图片处理专家
|
||||
支持图片上传、剪贴板粘贴、图片分析和基于图片的代码生成。
|
||||
|
||||
Pipeline: 图片输入 → 图片分析 → 任务路由 → 代码生成/图片描述
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from kaiwu.core.context import TaskContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Vision system prompts ──────────────────────────────────────────
|
||||
|
||||
VISION_ANALYSIS_SYSTEM = """\
|
||||
你是KWCode的多模态视觉专家。用户上传了一张图片,请根据图片内容提供有用的分析。
|
||||
|
||||
## 分析类型
|
||||
1. **代码截图分析**:如果图片包含代码、错误信息、终端输出
|
||||
- 识别代码语言和功能
|
||||
- 分析错误信息并提供修复建议
|
||||
- 如果用户有相关任务,提供代码修改建议
|
||||
|
||||
2. **UI/设计图分析**:如果图片是UI设计、网页截图、界面原型
|
||||
- 描述布局和设计元素
|
||||
- 提供实现建议(HTML/CSS/框架)
|
||||
- 识别可复用的组件
|
||||
|
||||
3. **文档/表格分析**:如果图片是文档、表格、图表
|
||||
- 提取关键信息
|
||||
- 结构化数据
|
||||
- 提供数据处理建议
|
||||
|
||||
4. **通用图片分析**:其他类型图片
|
||||
- 详细描述图片内容
|
||||
- 识别关键元素
|
||||
- 提供相关建议
|
||||
|
||||
## 输出格式
|
||||
- 简洁明了,2-5句话
|
||||
- 如果是代码相关,提供具体的修改建议
|
||||
- 如果是UI设计,提供实现方向
|
||||
- 使用中文回复"""
|
||||
|
||||
VISION_CODEGEN_SYSTEM = """\
|
||||
你是KWCode的多模态代码生成专家。用户上传了图片并要求生成代码。
|
||||
|
||||
## 代码生成规则
|
||||
1. **UI截图 → HTML/CSS**
|
||||
- 使用Tailwind CSS
|
||||
- 响应式设计
|
||||
- 保持视觉一致性
|
||||
|
||||
2. **错误截图 → 修复代码**
|
||||
- 分析错误信息
|
||||
- 定位问题根源
|
||||
- 提供最小化修复
|
||||
|
||||
3. **设计图 → 组件代码**
|
||||
- 选择合适的框架(React/Vue/原生)
|
||||
- 模块化组件设计
|
||||
- 可复用性优先
|
||||
|
||||
## 输出要求
|
||||
- 只输出完整可执行的代码
|
||||
- 不要解释,不要markdown代码块标记
|
||||
- 代码末尾添加使用说明注释"""
|
||||
|
||||
|
||||
class VisionExpert:
|
||||
"""多模态图片处理专家:分析图片内容,生成代码或提供分析"""
|
||||
|
||||
def __init__(self, llm, tool_executor=None):
|
||||
self.llm = llm
|
||||
self.tools = tool_executor
|
||||
self._temp_dir = Path(tempfile.mkdtemp(prefix="kwcode_vision_"))
|
||||
|
||||
def run(self, ctx: TaskContext) -> dict:
|
||||
"""
|
||||
处理图片输入任务
|
||||
|
||||
Args:
|
||||
ctx: 任务上下文,包含 user_input 和 image_path
|
||||
|
||||
Returns:
|
||||
dict: 包含 success, output, metadata
|
||||
"""
|
||||
image_path = getattr(ctx, 'image_path', None)
|
||||
|
||||
if not image_path:
|
||||
return {
|
||||
"success": False,
|
||||
"output": "错误:未提供图片路径",
|
||||
"metadata": {"error": "no_image_path"}
|
||||
}
|
||||
|
||||
# 验证图片文件
|
||||
if not self._validate_image(image_path):
|
||||
return {
|
||||
"success": False,
|
||||
"output": f"错误:图片文件不存在或格式不支持: {image_path}",
|
||||
"metadata": {"error": "invalid_image"}
|
||||
}
|
||||
|
||||
# 分析用户意图
|
||||
user_input = ctx.user_input.strip()
|
||||
is_codegen_task = self._is_codegen_task(user_input)
|
||||
|
||||
try:
|
||||
# 编码图片
|
||||
image_base64 = self._encode_image(image_path)
|
||||
|
||||
if is_codegen_task:
|
||||
return self._run_codegen(ctx, image_base64, image_path)
|
||||
else:
|
||||
return self._run_analysis(ctx, image_base64, image_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VisionExpert error: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"output": f"图片处理失败: {str(e)}",
|
||||
"metadata": {"error": str(e)}
|
||||
}
|
||||
|
||||
def _validate_image(self, image_path: str) -> bool:
|
||||
"""验证图片文件是否存在且格式支持"""
|
||||
path = Path(image_path)
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
# 支持的图片格式
|
||||
supported_formats = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'}
|
||||
return path.suffix.lower() in supported_formats
|
||||
|
||||
def _encode_image(self, image_path: str) -> str:
|
||||
"""将图片编码为base64"""
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode('utf-8')
|
||||
|
||||
def _is_codegen_task(self, user_input: str) -> bool:
|
||||
"""判断是否为代码生成任务"""
|
||||
codegen_keywords = [
|
||||
"生成代码", "写代码", "实现", "创建", "生成",
|
||||
"代码", "函数", "类", "组件", "页面",
|
||||
"generate", "create", "implement", "code",
|
||||
"html", "css", "javascript", "python", "react", "vue"
|
||||
]
|
||||
|
||||
# 检查是否包含代码相关关键词
|
||||
user_input_lower = user_input.lower()
|
||||
return any(kw in user_input_lower for kw in codegen_keywords)
|
||||
|
||||
def _run_analysis(self, ctx: TaskContext, image_base64: str, image_path: str) -> dict:
|
||||
"""运行图片分析"""
|
||||
logger.info(f"[vision] 分析图片: {image_path}")
|
||||
|
||||
# 构建提示词
|
||||
user_input = ctx.user_input.strip()
|
||||
if user_input:
|
||||
prompt = f"用户上传了一张图片并说:{user_input}\n\n请分析这张图片。"
|
||||
else:
|
||||
prompt = "用户上传了一张图片,请分析其内容。"
|
||||
|
||||
# 调用Vision LLM
|
||||
response = self._call_vision_llm(
|
||||
system_prompt=VISION_ANALYSIS_SYSTEM,
|
||||
user_prompt=prompt,
|
||||
image_base64=image_base64
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"output": response,
|
||||
"metadata": {
|
||||
"type": "vision_analysis",
|
||||
"image_path": image_path,
|
||||
"has_user_task": bool(user_input)
|
||||
}
|
||||
}
|
||||
|
||||
def _run_codegen(self, ctx: TaskContext, image_base64: str, image_path: str) -> dict:
|
||||
"""运行基于图片的代码生成"""
|
||||
logger.info(f"[vision] 基于图片生成代码: {image_path}")
|
||||
|
||||
user_input = ctx.user_input.strip()
|
||||
|
||||
# 构建提示词
|
||||
prompt = f"用户上传了一张图片并要求:{user_input}\n\n请根据图片内容生成代码。"
|
||||
|
||||
# 调用Vision LLM
|
||||
response = self._call_vision_llm(
|
||||
system_prompt=VISION_CODEGEN_SYSTEM,
|
||||
user_prompt=prompt,
|
||||
image_base64=image_base64
|
||||
)
|
||||
|
||||
# 尝试执行生成的代码(如果用户要求)
|
||||
if self.tools and self._should_execute_code(user_input):
|
||||
execution_result = self._execute_generated_code(response)
|
||||
if execution_result:
|
||||
response += f"\n\n--- 执行结果 ---\n{execution_result}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"output": response,
|
||||
"metadata": {
|
||||
"type": "vision_codegen",
|
||||
"image_path": image_path,
|
||||
"task": user_input
|
||||
}
|
||||
}
|
||||
|
||||
def _call_vision_llm(self, system_prompt: str, user_prompt: str, image_base64: str) -> str:
|
||||
"""调用支持Vision的LLM (Anthropic Messages API 格式)
|
||||
|
||||
优先使用 self.llm (如果支持 vision),否则回退到环境变量配置的 API:
|
||||
KWCODE_VISION_API_URL - API endpoint (默认 Anthropic 格式)
|
||||
KWCODE_VISION_API_KEY - API key
|
||||
KWCODE_VISION_MODEL - 模型名 (默认 mimo-v2-omni)
|
||||
"""
|
||||
# 尝试通过 self.llm 直接调用(如果后端支持多模态)
|
||||
if self.llm is not None:
|
||||
try:
|
||||
return self._try_llm_vision(system_prompt, user_prompt, image_base64)
|
||||
except (AttributeError, TypeError, Exception) as e:
|
||||
logger.debug(f"[vision] self.llm 不支持 vision,回退到 API: {e}")
|
||||
|
||||
# 回退:直接调用 Anthropic Messages API
|
||||
return self._call_anthropic_vision(system_prompt, user_prompt, image_base64)
|
||||
|
||||
def _try_llm_vision(self, system_prompt: str, user_prompt: str, image_base64: str) -> str:
|
||||
"""尝试通过 self.llm 的 chat 接口发送多模态请求"""
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": user_prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{image_base64}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
return self.llm.chat(messages, max_tokens=2048)
|
||||
|
||||
def _call_anthropic_vision(self, system_prompt: str, user_prompt: str, image_base64: str) -> str:
|
||||
"""直接调用 Anthropic Messages API(兼容 xiaomimimo 等代理)"""
|
||||
import json as _json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
api_url = os.environ.get(
|
||||
"KWCODE_VISION_API_URL",
|
||||
"https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages",
|
||||
)
|
||||
api_key = os.environ.get("KWCODE_VISION_API_KEY", "")
|
||||
model = os.environ.get("KWCODE_VISION_MODEL", "mimo-v2-omni")
|
||||
|
||||
# 检测图片格式(从 base64 开头字节判断)
|
||||
media_type = "image/png"
|
||||
raw_sample = image_base64[:20]
|
||||
if raw_sample.startswith("/9j"):
|
||||
media_type = "image/jpeg"
|
||||
elif raw_sample.startswith("R0lGOD"):
|
||||
media_type = "image/gif"
|
||||
elif raw_sample.startswith("UklGR"):
|
||||
media_type = "image/webp"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": 2048,
|
||||
"system": system_prompt,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": image_base64,
|
||||
},
|
||||
},
|
||||
{"type": "text", "text": user_prompt},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
if api_key:
|
||||
headers["x-api-key"] = api_key
|
||||
|
||||
logger.info(f"[vision] 调用 {api_url} model={model}")
|
||||
data_bytes = _json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(api_url, data=data_bytes, headers=headers, method="POST")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as resp:
|
||||
result = _json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"Vision API HTTP {e.code}: {body}") from e
|
||||
|
||||
# 提取文本
|
||||
# 检查 API 级别错误
|
||||
if result.get("type") == "error" or result.get("isError"):
|
||||
error_msg = result.get("error", {}).get("message", str(result))
|
||||
raise RuntimeError(f"Vision API error: {error_msg}")
|
||||
|
||||
stop_reason = result.get("stop_reason", "")
|
||||
if stop_reason == "max_tokens":
|
||||
logger.warning("[vision] 输出被截断 (max_tokens reached)")
|
||||
|
||||
text_parts = []
|
||||
for block in result.get("content", []):
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block["text"])
|
||||
|
||||
usage = result.get("usage", {})
|
||||
logger.info(
|
||||
f"[vision] 完成: {usage.get('input_tokens', '?')} in / "
|
||||
f"{usage.get('output_tokens', '?')} out tokens"
|
||||
)
|
||||
return "\n".join(text_parts) if text_parts else "[VisionExpert] 模型未返回文本内容"
|
||||
|
||||
def _should_execute_code(self, user_input: str) -> bool:
|
||||
"""判断是否应该执行生成的代码"""
|
||||
execute_keywords = ["执行", "运行", "测试", "run", "execute", "test"]
|
||||
return any(kw in user_input.lower() for kw in execute_keywords)
|
||||
|
||||
def _execute_generated_code(self, code: str) -> Optional[str]:
|
||||
"""执行生成的代码"""
|
||||
if not self.tools:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 保存代码到临时文件
|
||||
temp_file = self._temp_dir / "generated_code.py"
|
||||
temp_file.write_text(code, encoding='utf-8')
|
||||
|
||||
# 执行代码
|
||||
result = self.tools.run_bash(f"python {temp_file}")
|
||||
return result.get("output", "")
|
||||
|
||||
except Exception as e:
|
||||
return f"执行失败: {str(e)}"
|
||||
|
||||
def cleanup(self):
|
||||
"""清理临时文件"""
|
||||
import shutil
|
||||
if self._temp_dir.exists():
|
||||
shutil.rmtree(self._temp_dir)
|
||||
|
||||
|
||||
def save_clipboard_image() -> Optional[str]:
|
||||
"""
|
||||
从剪贴板保存图片
|
||||
|
||||
Returns:
|
||||
str: 保存的图片路径,如果剪贴板没有图片则返回None
|
||||
"""
|
||||
try:
|
||||
from PIL import Image, ImageGrab
|
||||
|
||||
image = ImageGrab.grabclipboard()
|
||||
if isinstance(image, Image.Image):
|
||||
# 生成临时文件路径
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="kwcode_clipboard_"))
|
||||
temp_path = temp_dir / "clipboard_image.png"
|
||||
|
||||
# 保存图片
|
||||
image.save(temp_path, "PNG")
|
||||
logger.info(f"[vision] 剪贴板图片已保存: {temp_path}")
|
||||
|
||||
return str(temp_path)
|
||||
else:
|
||||
logger.debug("[vision] 剪贴板中没有图片")
|
||||
return None
|
||||
|
||||
except ImportError:
|
||||
logger.warning("[vision] Pillow未安装,无法处理剪贴板图片")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[vision] 处理剪贴板图片失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def validate_image_path(path: str) -> bool:
|
||||
"""验证图片路径是否有效"""
|
||||
path_obj = Path(path)
|
||||
if not path_obj.exists():
|
||||
return False
|
||||
|
||||
supported_formats = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'}
|
||||
return path_obj.suffix.lower() in supported_formats
|
||||
|
||||
|
||||
def get_image_info(image_path: str) -> dict:
|
||||
"""获取图片基本信息"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
return {
|
||||
"format": img.format,
|
||||
"size": img.size,
|
||||
"mode": img.mode,
|
||||
"file_size": os.path.getsize(image_path)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
@@ -30,6 +30,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
rerank = ["sentence-transformers>=2.7.0"]
|
||||
multimodal = ["Pillow>=10.0", "pyperclip>=1.8"]
|
||||
|
||||
[project.scripts]
|
||||
kwcode = "kaiwu.cli.main:app"
|
||||
|
||||
Reference in New Issue
Block a user