KaiWu commited on
Commit
219250e
·
1 Parent(s): 557e100

feat(app): 新增 Gradio Web UI 作为 MVP 交互入口

Browse files

- app.py: Gradio Blocks 页面,左侧对话 + 多模态输入(文本/图片),
右侧 3D 预览(Model3D)+ 文件下载 + 运行状态,支持会话隔离与重置
- service.py: run_chat_turn 封装一次对话闭环,从 agent_loop 消息流
抽取 assistant 文本、工具 payload、预览/下载路径,供 Web 层直接消费
- 保留终端入口 cli.py 不受影响

Files changed (2) hide show
  1. agent_core/service.py +75 -0
  2. app.py +153 -0
agent_core/service.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from agent_core.agent import agent_loop
5
+ from agent_core.config import WORKDIR
6
+
7
+
8
+ def content_to_text(content) -> str:
9
+ if isinstance(content, str):
10
+ return content
11
+ if not isinstance(content, list):
12
+ return ""
13
+
14
+ texts = []
15
+ for block in content:
16
+ text = getattr(block, "text", None)
17
+ if text:
18
+ texts.append(text)
19
+ return "\n".join(texts).strip()
20
+
21
+
22
+ def resolve_display_path(path: str | None) -> str | None:
23
+ if not path:
24
+ return None
25
+
26
+ candidate = Path(path)
27
+ if not candidate.is_absolute():
28
+ candidate = WORKDIR / candidate
29
+ candidate = candidate.resolve()
30
+ return str(candidate) if candidate.exists() else None
31
+
32
+
33
+ def latest_tool_payload(messages: list) -> dict:
34
+ latest = {}
35
+ for message in messages:
36
+ content = message.get("content")
37
+ if not isinstance(content, list):
38
+ continue
39
+ for item in content:
40
+ if not isinstance(item, dict) or item.get("type") != "tool_result":
41
+ continue
42
+ try:
43
+ payload = json.loads(item.get("content", ""))
44
+ except json.JSONDecodeError:
45
+ continue
46
+ if isinstance(payload, dict):
47
+ latest = payload
48
+ return latest
49
+
50
+
51
+ def run_chat_turn(messages: list, user_text: str, image_path: str | None = None) -> dict:
52
+ text = user_text.strip()
53
+ if image_path:
54
+ text = f"{text}\n\nUploaded image path for image-to-3D generation: {image_path}".strip()
55
+
56
+ messages.append({"role": "user", "content": text})
57
+ agent_loop(messages)
58
+
59
+ assistant_text = content_to_text(messages[-1].get("content"))
60
+ tool_payload = latest_tool_payload(messages)
61
+ output_path = tool_payload.get("output_path")
62
+ preview_path = tool_payload.get("preview_path") or output_path
63
+
64
+ return {
65
+ "messages": messages,
66
+ "assistant_text": assistant_text,
67
+ "tool_payload": tool_payload,
68
+ "preview_path": resolve_display_path(preview_path),
69
+ "download_path": resolve_display_path(output_path),
70
+ "run_id": tool_payload.get("run_id"),
71
+ "run_dir": tool_payload.get("run_dir"),
72
+ "manifest_path": tool_payload.get("manifest_path"),
73
+ "ok": tool_payload.get("ok"),
74
+ "error": tool_payload.get("error"),
75
+ }
app.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+ import gradio as gr
7
+
8
+ from agent_core.config import ARTIFACT_ROOT
9
+ from agent_core.service import run_chat_turn
10
+
11
+
12
+ def new_session_state() -> dict:
13
+ return {
14
+ "session_id": uuid4().hex[:12],
15
+ "agent_messages": [],
16
+ "chat_messages": [],
17
+ }
18
+
19
+
20
+ def copy_uploaded_image(image_path: str | None, session_id: str) -> str | None:
21
+ if not image_path:
22
+ return None
23
+
24
+ source = Path(image_path)
25
+ suffix = source.suffix or ".png"
26
+ upload_dir = ARTIFACT_ROOT / "uploads" / session_id
27
+ upload_dir.mkdir(parents=True, exist_ok=True)
28
+ target = upload_dir / f"{uuid4().hex[:8]}{suffix}"
29
+ shutil.copy2(source, target)
30
+ return str(target)
31
+
32
+
33
+ def parse_user_message(message) -> tuple[str, str | None]:
34
+ if message is None:
35
+ return "", None
36
+ if isinstance(message, str):
37
+ return message.strip(), None
38
+
39
+ text = (message.get("text") or "").strip()
40
+ files = message.get("files") or []
41
+ image_path = files[0] if files else None
42
+ return text, image_path
43
+
44
+
45
+ def format_status(result: dict) -> str:
46
+ payload = result.get("tool_payload") or {}
47
+ if not payload:
48
+ return "No model generated yet."
49
+
50
+ if payload.get("ok"):
51
+ return "\n".join([
52
+ f"Run: `{payload.get('run_id')}`",
53
+ f"Output: `{payload.get('output_path')}`",
54
+ f"Preview: `{payload.get('preview_path') or payload.get('output_path')}`",
55
+ f"Manifest: `{payload.get('manifest_path')}`",
56
+ ])
57
+
58
+ return "\n".join([
59
+ "Generation failed.",
60
+ f"Stage: `{payload.get('stage')}`",
61
+ f"Error: `{payload.get('error')}`",
62
+ f"Manifest: `{payload.get('manifest_path')}`" if payload.get("manifest_path") else "",
63
+ ]).strip()
64
+
65
+
66
+ def submit_message(message, state):
67
+ state = state or new_session_state()
68
+ text, image_path = parse_user_message(message)
69
+ uploaded_image = copy_uploaded_image(image_path, state["session_id"])
70
+
71
+ if not text and not uploaded_image:
72
+ return state["chat_messages"], state, None, None, "Enter a message or upload an image.", {"text": "", "files": []}
73
+
74
+ display_text = text or "Generate a 3D model from the uploaded image."
75
+ if uploaded_image:
76
+ display_text = f"{display_text}\n\n[uploaded image]"
77
+ state["chat_messages"].append({"role": "user", "content": display_text})
78
+
79
+ try:
80
+ result = run_chat_turn(
81
+ messages=state["agent_messages"],
82
+ user_text=text or "Generate a 3D model from the uploaded image.",
83
+ image_path=uploaded_image,
84
+ )
85
+ state["agent_messages"] = result["messages"]
86
+ assistant_text = result["assistant_text"] or "Done."
87
+ if result.get("error") and not assistant_text:
88
+ assistant_text = result["error"]
89
+ state["chat_messages"].append({"role": "assistant", "content": assistant_text})
90
+ status = format_status(result)
91
+ return (
92
+ state["chat_messages"],
93
+ state,
94
+ result.get("preview_path"),
95
+ result.get("download_path"),
96
+ status,
97
+ {"text": "", "files": []},
98
+ )
99
+ except Exception as exc:
100
+ state["chat_messages"].append({"role": "assistant", "content": f"Failed: {exc}"})
101
+ return state["chat_messages"], state, None, None, f"Failed: `{exc}`", {"text": "", "files": []}
102
+
103
+
104
+ def reset_session():
105
+ state = new_session_state()
106
+ return [], state, None, None, "New session started.", {"text": "", "files": []}
107
+
108
+
109
+ with gr.Blocks(title="AI CAD Agent", fill_height=True) as demo:
110
+ gr.Markdown("# AI CAD Agent")
111
+
112
+ state = gr.State(new_session_state())
113
+
114
+ with gr.Row():
115
+ with gr.Column(scale=5):
116
+ chatbot = gr.Chatbot(height=560, label="Conversation")
117
+ composer = gr.MultimodalTextbox(
118
+ sources=["upload"],
119
+ file_types=["image"],
120
+ file_count="single",
121
+ lines=2,
122
+ max_lines=6,
123
+ label="Message",
124
+ placeholder="Describe a CAD model, ask for an edit, or upload an image as reference.",
125
+ submit_btn="Send",
126
+ )
127
+ with gr.Row():
128
+ clear = gr.Button("New Session")
129
+
130
+ with gr.Column(scale=4):
131
+ preview = gr.Model3D(label="Model Preview", height=560)
132
+ download = gr.File(label="Download Latest Model")
133
+ status = gr.Markdown("No model generated yet.")
134
+
135
+ composer.submit(
136
+ submit_message,
137
+ inputs=[composer, state],
138
+ outputs=[chatbot, state, preview, download, status, composer],
139
+ )
140
+ clear.click(
141
+ reset_session,
142
+ outputs=[chatbot, state, preview, download, status, composer],
143
+ )
144
+
145
+
146
+ if __name__ == "__main__":
147
+ server_name = os.getenv("GRADIO_SERVER_NAME", "127.0.0.1")
148
+ server_port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
149
+ demo.queue(default_concurrency_limit=1).launch(
150
+ server_name=server_name,
151
+ server_port=server_port,
152
+ allowed_paths=[str(ARTIFACT_ROOT)],
153
+ )