Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i
zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7
zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG
z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S
zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr
z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S
zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er
zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa
zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc-
zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V
zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I
zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc
z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E(
zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef
LrJugUA?W`A8`#=m
literal 0
HcmV?d00001
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
new file mode 100644
index 0000000..bf88350
--- /dev/null
+++ b/frontend/src/app/globals.css
@@ -0,0 +1,96 @@
+@import "tailwindcss";
+
+:root {
+ --background: #ffffff;
+ --foreground: #171717;
+
+ /* Custom Theme Variables from login.html/index.html */
+ --bg0: #070a12;
+ --bg1: #0b1022;
+ --card: rgba(255, 255, 255, 0.06);
+ --card2: rgba(255, 255, 255, 0.08);
+ --text: rgba(255, 255, 255, 0.92);
+ --muted: rgba(255, 255, 255, 0.72);
+ --muted2: rgba(255, 255, 255, 0.56);
+ --border: rgba(255, 255, 255, 0.12);
+ --primary: #62f6c7;
+ --primary2: #5aa9ff;
+ --danger: #ff5a7a;
+ --shadow: 0 22px 60px rgba(0, 0, 0, 0.55);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: Arial, Helvetica, sans-serif;
+ --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+ }
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: Arial, Helvetica, sans-serif;
+}
+
+/* Utility classes for the custom theme */
+.theme-bg-gradient {
+ background: radial-gradient(1200px 600px at 20% 18%, rgba(98, 246, 199, 0.24), rgba(98, 246, 199, 0) 55%),
+ radial-gradient(900px 560px at 78% 20%, rgba(90, 169, 255, 0.22), rgba(90, 169, 255, 0) 55%),
+ radial-gradient(900px 640px at 55% 78%, rgba(255, 90, 122, 0.12), rgba(255, 90, 122, 0) 55%),
+ linear-gradient(180deg, var(--bg0), var(--bg1));
+}
+
+.theme-card {
+ border: 1px solid var(--border);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
+ box-shadow: var(--shadow);
+}
+
+.theme-input {
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ background: rgba(0, 0, 0, 0.18);
+ color: var(--text);
+}
+
+.theme-input:focus {
+ border-color: rgba(98, 246, 199, 0.55);
+ box-shadow: 0 0 0 4px rgba(98, 246, 199, 0.12);
+}
+
+.theme-btn {
+ background: linear-gradient(135deg, var(--primary), var(--primary2));
+ color: rgba(7, 10, 18, 0.92);
+ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.35);
+}
+
+.theme-btn-secondary {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #cbd5e1; /* slate-300 */
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8; /* slate-400 */
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
new file mode 100644
index 0000000..15d3ce2
--- /dev/null
+++ b/frontend/src/app/layout.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "Agent-Draw-IO",
+ description: "智能体交互绘图 @小傅哥",
+};
+
+import Script from "next/script";
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx
new file mode 100644
index 0000000..5373987
--- /dev/null
+++ b/frontend/src/app/login/page.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { setUserInfo, getUserInfo, clearUserInfo } from '@/utils/cookie';
+
+export default function Login() {
+ const router = useRouter();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [msg, setMsg] = useState({ text: '', type: '' });
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [currentUser, setCurrentUser] = useState('');
+
+ useEffect(() => {
+ const userInfo = getUserInfo();
+ if (userInfo && userInfo.user) {
+ setIsLoggedIn(true);
+ setCurrentUser(userInfo.user);
+ // If already logged in, redirect to home
+ router.push('/');
+ }
+ }, [router]);
+
+ const handleLogin = (e: React.FormEvent) => {
+ e.preventDefault();
+ setMsg({ text: '', type: '' });
+
+ if (!username || !password) {
+ setMsg({ text: '请输入账号与密码。', type: 'error' });
+ return;
+ }
+
+ if (username !== 'admin' || password !== 'admin') {
+ setMsg({ text: '账号或密码错误(演示账号:admin / admin)。', type: 'error' });
+ return;
+ }
+
+ setUserInfo(username);
+ setMsg({ text: '登录成功,正在跳转…', type: 'info' });
+ setTimeout(() => {
+ router.push('/');
+ }, 500);
+ };
+
+ const handleFillDemo = () => {
+ setUsername('admin');
+ setPassword('admin');
+ setMsg({ text: '已填充演示账号。', type: 'info' });
+ };
+
+ const handleLogout = () => {
+ clearUserInfo();
+ setIsLoggedIn(false);
+ setCurrentUser('');
+ setMsg({ text: '已退出登录,cookie 已清除。', type: 'info' });
+ };
+
+ return (
+
+
+ {/* Hero Section */}
+
+
+
+ AI
+
+
+
+ AI 智能体工作台 By Ai Agent Scaffold - @小傅哥
+
+ 更快搭建 · 更稳运行 · 更易运维
+
+
+
+
+ 一个能“帮你把事做完”的智能体登录页
+
+
+ 左侧展示智能体能力与效果图,右侧进行登录。当前为演示登录:
+ 账号 admin,密码 admin。登录成功后会在浏览器保存 cookie。
+
+
+
+ {[
+ { title: '工具调用', desc: '支持 API / Shell / 文件等执行链路编排' },
+ { title: '记忆与上下文', desc: '可配置可审计,减少重复沟通成本' },
+ { title: '多模型路由', desc: '按场景选择最合适的模型与策略' },
+ { title: '可观测性', desc: '链路、成本、失败原因都能追踪' },
+ ].map((item, idx) => (
+
+
+
+ {item.title}
+ {item.desc}
+
+
+ ))}
+
+
+
+ {/* Placeholder for Hero Image - mimicking the original svg placeholder */}
+
+ AI 智能体效果图
+
+
+
+
+ {/* Login Form Section */}
+
+
+ 登录
+
+ 演示账号:admin / admin(可在页面脚本中替换成真实鉴权接口)
+
+
+ {!isLoggedIn ? (
+
+ ) : (
+
+
+ 已登录:{currentUser}
+ 欢迎回来
+
+
+
+ )}
+
+
+ {msg.text}
+
+
+
+
+ © AI Agent Scaffold · Next.js 页面示例
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
new file mode 100644
index 0000000..ef6437c
--- /dev/null
+++ b/frontend/src/app/page.tsx
@@ -0,0 +1,998 @@
+'use client';
+
+import { DrawIoEmbed, DrawIoEmbedRef } from 'react-drawio';
+import { useRef, useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { getUserInfo, clearUserInfo } from '@/utils/cookie';
+import { agentApi } from '@/api/agent';
+import { AiAgentConfigResponseDTO } from '@/types/api';
+
+// Message type definition
+type Message = {
+ id: string;
+ role: 'user' | 'agent';
+ content: string;
+ timestamp: number;
+};
+
+const isDrawIoXmlContent = (content: string) => {
+ const trimmed = content.trim();
+ return trimmed.startsWith(' (
+
+ ),
+ Close: ({ className }: { className?: string }) => (
+
+ ),
+ Send: ({ className }: { className?: string }) => (
+
+ ),
+ User: ({ className }: { className?: string }) => (
+
+ ),
+ Bot: ({ className }: { className?: string }) => (
+
+ ),
+ Download: ({ className }: { className?: string }) => (
+
+ ),
+ Sparkles: ({ className }: { className?: string }) => (
+
+ ),
+ Logout: ({ className }: { className?: string }) => (
+
+ ),
+ Layers: ({ className }: { className?: string }) => (
+
+ ),
+ Loader: ({ className }: { className?: string }) => (
+
+ ),
+ Plus: ({ className }: { className?: string }) => (
+
+ ),
+ Trash: ({ className }: { className?: string }) => (
+
+ ),
+ MessageSquare: ({ className }: { className?: string }) => (
+
+ )
+};
+
+interface Session {
+ id: string;
+ backendSessionId?: string;
+ title: string;
+ messages: Message[];
+ drawIoXml: string | null;
+ lastModified: number;
+}
+
+export default function Home() {
+ const router = useRouter();
+ const [imgData, setImgData] = useState(null);
+ const drawioRef = useRef(null);
+
+ // User State
+ const [currentUser, setCurrentUser] = useState('');
+
+ // Chat State
+ const [isChatOpen, setIsChatOpen] = useState(true);
+ const [messages, setMessages] = useState([
+ {
+ id: '1',
+ role: 'agent',
+ content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
+ timestamp: Date.now()
+ }
+ ]);
+ const [inputValue, setInputValue] = useState('');
+ const [isSending, setIsSending] = useState(false);
+ const messagesEndRef = useRef(null);
+
+ // Context State
+ const [useHistoryContext, setUseHistoryContext] = useState(false);
+ const [lastExportedData, setLastExportedData] = useState<{data: string, timestamp: number} | null>(null);
+ const isExportingForChatRef = useRef(false);
+ const isAutosaveRef = useRef(false);
+ const pendingMessageRef = useRef('');
+ const [isDrawIoReady, setIsDrawIoReady] = useState(false);
+ const initialLoadDoneRef = useRef(false);
+
+ // Agent State
+ const [agents, setAgents] = useState([]);
+ const [selectedAgentId, setSelectedAgentId] = useState('');
+ const [sessionId, setSessionId] = useState('');
+
+ // Rename State
+ const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
+ const [renamingSessionId, setRenamingSessionId] = useState(null);
+ const [newSessionTitle, setNewSessionTitle] = useState('');
+
+ // Session Management State
+ const [sessions, setSessions] = useState([]);
+ const [currentSessionId, setCurrentSessionId] = useState(null);
+ const currentSessionRef = useRef(currentSessionId);
+
+ // Update ref
+ useEffect(() => {
+ currentSessionRef.current = currentSessionId;
+ }, [currentSessionId]);
+
+ // Handle Initial Load
+ useEffect(() => {
+ if (!initialLoadDoneRef.current && isDrawIoReady && currentSessionId && sessions.length > 0) {
+ const session = sessions.find(s => s.id === currentSessionId);
+ if (session && session.drawIoXml && drawioRef.current) {
+ drawioRef.current.load({ xml: session.drawIoXml });
+ }
+ initialLoadDoneRef.current = true;
+ }
+ }, [isDrawIoReady, currentSessionId, sessions]);
+
+ // Load sessions from localStorage
+ useEffect(() => {
+ const savedSessions = localStorage.getItem('drawio_sessions');
+ if (savedSessions) {
+ try {
+ const parsed = JSON.parse(savedSessions);
+ setSessions(parsed);
+ if (parsed.length > 0) {
+ // Load the most recent session (first one if sorted by lastModified desc)
+ const mostRecent = parsed.sort((a: Session, b: Session) => b.lastModified - a.lastModified)[0];
+ setCurrentSessionId(mostRecent.id);
+ setMessages(mostRecent.messages);
+ // Note: Draw.io XML loading happens after drawioRef is ready or when we switch
+ } else {
+ createNewSession(true);
+ }
+ } catch (e) {
+ console.error('Failed to parse sessions:', e);
+ createNewSession(true);
+ }
+ } else {
+ createNewSession(true);
+ }
+ }, []);
+
+ // Save sessions to localStorage whenever they change
+ useEffect(() => {
+ if (sessions.length > 0) {
+ try {
+ localStorage.setItem('drawio_sessions', JSON.stringify(sessions));
+ } catch (e) {
+ console.error('Failed to save sessions to localStorage:', e);
+ }
+ }
+ }, [sessions]);
+
+ // Update current session messages and backendSessionId when they change
+ useEffect(() => {
+ if (currentSessionId) {
+ setSessions(prev => prev.map(session => {
+ if (session.id === currentSessionId) {
+ return {
+ ...session,
+ messages,
+ backendSessionId: sessionId,
+ // Update title if it's the default "New Chat" and we have a user message
+ title: session.title === 'New Chat' && messages.find(m => m.role === 'user')
+ ? (messages.find(m => m.role === 'user')?.content.slice(0, 20) || 'New Chat')
+ : session.title
+ };
+ }
+ return session;
+ }));
+ }
+ }, [messages, currentSessionId, sessionId]);
+
+ const createNewSession = (isInitial = false, backendId = '') => {
+ const newSession: Session = {
+ id: Date.now().toString(),
+ backendSessionId: backendId,
+ title: 'New Chat',
+ messages: [{
+ id: Date.now().toString(),
+ role: 'agent',
+ content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
+ timestamp: Date.now()
+ }],
+ drawIoXml: null,
+ lastModified: Date.now()
+ };
+
+ setSessions(prev => [newSession, ...prev]);
+ setCurrentSessionId(newSession.id);
+ setMessages(newSession.messages);
+ setSessionId(backendId);
+
+ if (!isInitial && drawioRef.current) {
+ drawioRef.current.load({ xml: '' }); // Clear diagram
+ }
+ };
+
+ const handleSwitchSession = (targetSessionId: string) => {
+ if (targetSessionId === currentSessionId) return;
+ loadSession(targetSessionId);
+ };
+
+ const loadSession = (targetSessionId: string) => {
+ const session = sessions.find(s => s.id === targetSessionId);
+ if (session) {
+ setCurrentSessionId(targetSessionId);
+ setMessages(session.messages);
+ setSessionId(session.backendSessionId || '');
+ if (drawioRef.current && session.drawIoXml) {
+ drawioRef.current.load({ xml: session.drawIoXml });
+ } else if (drawioRef.current) {
+ drawioRef.current.load({ xml: '' });
+ }
+ }
+ };
+
+ const handleDeleteSession = (e: React.MouseEvent, sessionIdToDelete: string) => {
+ e.stopPropagation();
+ const newSessions = sessions.filter(s => s.id !== sessionIdToDelete);
+ setSessions(newSessions);
+ localStorage.setItem('drawio_sessions', JSON.stringify(newSessions));
+
+ if (currentSessionId === sessionIdToDelete) {
+ if (newSessions.length > 0) {
+ loadSession(newSessions[0].id);
+ } else {
+ createNewSession();
+ }
+ }
+ };
+
+ const handleDoubleClickSession = (session: Session) => {
+ setRenamingSessionId(session.id);
+ setNewSessionTitle(session.title);
+ setIsRenameModalOpen(true);
+ };
+
+ const handleRenameSave = () => {
+ if (renamingSessionId && newSessionTitle.trim()) {
+ setSessions(prev => prev.map(s =>
+ s.id === renamingSessionId ? { ...s, title: newSessionTitle.trim() } : s
+ ));
+ setIsRenameModalOpen(false);
+ setRenamingSessionId(null);
+ setNewSessionTitle('');
+ }
+ };
+
+ const exportDiagram = () => {
+ if (drawioRef.current) {
+ drawioRef.current.exportDiagram({
+ format: 'xmlsvg'
+ });
+ }
+ };
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages, isChatOpen]);
+
+ // Check Login & Load Agents
+ useEffect(() => {
+ const userInfo = getUserInfo();
+ if (!userInfo || !userInfo.user) {
+ router.push('/login');
+ return;
+ }
+ setCurrentUser(userInfo.user);
+
+ // Load Agents
+ const loadAgents = async () => {
+ try {
+ const res = await agentApi.queryAiAgentConfigList();
+ setAgents(res.data || []);
+ if (res.data && res.data.length > 0) {
+ // Try to restore last agent or default to first
+ const lastAgentId = localStorage.getItem('ai_agent_last_agent');
+ if (lastAgentId && res.data.find(a => a.agentId === lastAgentId)) {
+ setSelectedAgentId(lastAgentId);
+ } else {
+ setSelectedAgentId(res.data[0].agentId);
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load agents:', error);
+ setMessages(prev => [...prev, {
+ id: Date.now().toString(),
+ role: 'agent',
+ content: '加载智能体列表失败,请检查后端服务是否启动。',
+ timestamp: Date.now()
+ }]);
+ }
+ };
+ loadAgents();
+ }, [router]);
+
+ const handleLogout = () => {
+ clearUserInfo();
+ router.push('/login');
+ };
+
+ const handleAgentChange = (e: React.ChangeEvent) => {
+ const newAgentId = e.target.value;
+ setSelectedAgentId(newAgentId);
+ setSessionId(''); // Reset session when agent changes
+ localStorage.setItem('ai_agent_last_agent', newAgentId);
+ };
+
+ const finalizeNewChat = async () => {
+ if (!selectedAgentId || !currentUser) return;
+
+ try {
+ const res = await agentApi.createSession(selectedAgentId, currentUser);
+ createNewSession(false, res.data.sessionId);
+ setInputValue('');
+ } catch (error) {
+ console.error('Failed to create new session:', error);
+ }
+ };
+
+ const handleNewChat = async () => {
+ finalizeNewChat();
+ };
+
+ const handleRestartSession = async () => {
+ if (!selectedAgentId || !currentUser) return;
+
+ if (!currentSessionId) {
+ finalizeNewChat();
+ return;
+ }
+
+ try {
+ const res = await agentApi.createSession(selectedAgentId, currentUser);
+ const newBackendId = res.data.sessionId;
+
+ const initialMsg: Message = {
+ id: Date.now().toString(),
+ role: 'agent',
+ content: '你好!我是你的智能架构助手。请选择一个智能体开始对话。',
+ timestamp: Date.now()
+ };
+
+ setSessionId(newBackendId);
+ setMessages([initialMsg]);
+ setInputValue('');
+
+ setSessions(prev => prev.map(session => {
+ if (session.id === currentSessionId) {
+ return {
+ ...session,
+ backendSessionId: newBackendId,
+ messages: [initialMsg],
+ lastModified: Date.now()
+ };
+ }
+ return session;
+ }));
+ } catch (error) {
+ console.error('Failed to restart session:', error);
+ }
+ };
+
+ const performSendMessage = async (displayContent: string, apiContent: string) => {
+ if (!selectedAgentId) {
+ setMessages(prev => [...prev, {
+ id: Date.now().toString(),
+ role: 'agent',
+ content: '请先选择一个智能体。',
+ timestamp: Date.now()
+ }]);
+ setIsSending(false);
+ return;
+ }
+
+ const userMsg: Message = {
+ id: Date.now().toString(),
+ role: 'user',
+ content: displayContent,
+ timestamp: Date.now()
+ };
+ setMessages(prev => [...prev, userMsg]);
+
+ try {
+ // 1. Ensure Session
+ let activeBackendSessionId = sessionId;
+ if (!activeBackendSessionId) {
+ const sessionRes = await agentApi.createSession(selectedAgentId, currentUser);
+ activeBackendSessionId = sessionRes.data.sessionId;
+ setSessionId(activeBackendSessionId);
+ }
+
+ // Update session lastModified
+ setSessions(prev => prev.map(session => {
+ if (session.id === currentSessionId) {
+ return { ...session, lastModified: Date.now() };
+ }
+ return session;
+ }));
+
+ // 2. Send Message
+ const chatRes = await agentApi.chat({
+ agentId: selectedAgentId,
+ userId: currentUser,
+ sessionId: activeBackendSessionId,
+ message: apiContent
+ });
+
+ const { content } = chatRes.data;
+
+ // Go backend returns { content }; infer whether the content is draw.io XML.
+ if (!isDrawIoXmlContent(content)) {
+ const agentMsg: Message = {
+ id: (Date.now() + 1).toString(),
+ role: 'agent',
+ content: content,
+ timestamp: Date.now()
+ };
+ setMessages(prev => [...prev, agentMsg]);
+ } else {
+ // Save to session immediately (always update the session that initiated the request)
+ setSessions(prev => prev.map(session => {
+ if (session.id === currentSessionId) {
+ return {
+ ...session,
+ drawIoXml: content,
+ lastModified: Date.now()
+ };
+ }
+ return session;
+ }));
+
+ // Render only if still on the same session
+ if (drawioRef.current && currentSessionId === currentSessionRef.current) {
+ try {
+ drawioRef.current.load({
+ xml: content
+ });
+ } catch (e) {
+ console.error('Failed to load diagram:', e);
+ }
+ }
+ }
+
+ } catch (error) {
+ console.error('Chat error:', error);
+ const errorMsg: Message = {
+ id: (Date.now() + 1).toString(),
+ role: 'agent',
+ content: error instanceof Error ? `Error: ${error.message}` : '发送失败,请重试。',
+ timestamp: Date.now()
+ };
+ setMessages(prev => [...prev, errorMsg]);
+ } finally {
+ setIsSending(false);
+ }
+ };
+
+ const handleSendMessage = async () => {
+ if (!inputValue.trim() || isSending) return;
+
+ const content = inputValue;
+ setInputValue('');
+ setIsSending(true);
+
+ if (useHistoryContext && drawioRef.current && isDrawIoReady) {
+ isExportingForChatRef.current = true;
+ pendingMessageRef.current = content;
+ try {
+ drawioRef.current.exportDiagram({
+ format: 'xml' as any
+ });
+ } catch (e) {
+ console.error("Export failed", e);
+ performSendMessage(content, content);
+ }
+ } else {
+ performSendMessage(content, content);
+ }
+ };
+
+ useEffect(() => {
+ if (!lastExportedData) return;
+
+ if (isExportingForChatRef.current) {
+ isExportingForChatRef.current = false;
+ const xml = lastExportedData.data;
+ const content = pendingMessageRef.current;
+ const apiContent = `[Context: Current Draw.io XML]\n\`\`\`xml\n${xml}\n\`\`\`\n\n${content}`;
+ performSendMessage(content, apiContent);
+ return;
+ }
+
+ // Autosave handling
+ if (isAutosaveRef.current) {
+ isAutosaveRef.current = false;
+ const xml = lastExportedData.data;
+ setSessions(prev => prev.map(s => {
+ if (s.id === currentSessionId) {
+ return { ...s, drawIoXml: xml };
+ }
+ return s;
+ }));
+ return;
+ }
+
+ // Manual Export
+ setImgData(lastExportedData.data);
+ }, [lastExportedData]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ };
+
+ const quickActions = [
+ { label: '绘制h5端登录流程图', text: '请帮我绘制一个H5端的登录流程图,包含用户输入手机号、获取验证码、验证登录等步骤。' },
+ { label: '绘制电商购物流程图', text: '请帮我绘制一个电商购物流程图,包含商品浏览、加入购物车、下单、支付、发货等环节。' }
+ ];
+
+ return (
+
+ {/* Header - Minimal & Clean */}
+
+
+
+
+
+
+ {currentUser || 'Guest'}
+
+
+
+
+
+
+
+
+ {!isChatOpen && (
+
+ )}
+
+
+
+ {/* Main Layout */}
+
+ {/* Sessions Sidebar */}
+
+
+
+
+ 绘图记录
+
+
+
+
+ {[...sessions].sort((a, b) => b.lastModified - a.lastModified).map(session => (
+ handleSwitchSession(session.id)}
+ onDoubleClick={(e) => { e.stopPropagation(); handleDoubleClickSession(session); }}
+ className={`
+ group flex items-center gap-3 px-3 py-3 rounded-lg cursor-pointer transition-all border border-transparent
+ ${currentSessionId === session.id
+ ? 'bg-indigo-50 text-indigo-700 border-indigo-100 shadow-sm'
+ : 'hover:bg-slate-50 text-slate-600 hover:text-slate-900'
+ }
+ `}
+ >
+
+
+ {session.title}
+
+
+ {new Date(session.lastModified).toLocaleDateString()} {new Date(session.lastModified).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
+
+
+
+
+ ))}
+ {sessions.length === 0 && (
+
+ No history yet
+
+ )}
+
+
+
+ {/* Draw.io Canvas Area */}
+
+
+ {
+ if (currentSessionId && isDrawIoReady && !isExportingForChatRef.current) {
+ // Prefer using the XML directly from the autosave event if available
+ if (data && typeof data === 'object' && 'xml' in data) {
+ const xmlContent = (data as any).xml;
+ setSessions(prev => prev.map(s => {
+ if (s.id === currentSessionId) {
+ return { ...s, drawIoXml: xmlContent };
+ }
+ return s;
+ }));
+ } else {
+ // Fallback to export if no XML provided in event
+ isAutosaveRef.current = true;
+ drawioRef.current?.exportDiagram({ format: 'xml' as any });
+ }
+ }
+ }}
+ onLoad={() => setIsDrawIoReady(true)}
+ onExport={(data) => setLastExportedData({ data: data.data, timestamp: Date.now() })}
+ urlParameters={{
+ ui: 'atlas', // More modern UI theme for draw.io
+ spin: true,
+ libraries: true,
+ saveAndExit: false,
+ noSaveBtn: true,
+ noExitBtn: true
+ }}
+ />
+
+
+
+ {/* Chat Sidebar - Modern & Elegant */}
+
+ {/* Chat Header */}
+
+
+
+
+
+
+
+
+
+ AI Assistant Online
+
+
+
+
+
+
+
+
+ {/* Messages Area */}
+
+ {messages.map((msg) => (
+
+
+ {msg.role === 'user' ? : }
+
+
+
+
+ {msg.role === 'user' ? 'You' : 'Agent'}
+
+
+ {msg.content}
+
+
+
+ ))}
+
+
+
+ {/* Input Area */}
+
+ {/* Quick Actions - Only show when chat is empty (just greeting) */}
+ {messages.length <= 1 && (
+
+ {quickActions.map((action, idx) => (
+
+ ))}
+
+ )}
+
+ {/* Context Toolbar */}
+
+
+
+ Press Ctrl/Command + Enter
+
+
+
+
+
+ {isSending ? 'AI is generating response...' : 'AI can make mistakes. Please verify important info.'}
+
+
+
+
+
+
+ {/* Export Modal - Polished */}
+ {imgData && (
+
+
+
+
+
+
+
+
+ Export Ready
+ Your diagram has been successfully converted
+
+
+
+
+
+
+
+ 
+
+
+
+
+
+
+
+ Download File
+
+
+
+
+ )}
+
+ {/* Rename Modal */}
+ {isRenameModalOpen && (
+
+
+
+ Rename Session
+
+
+
+
+
+ setNewSessionTitle(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleRenameSave()}
+ className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
+ placeholder="Enter new name"
+ autoFocus
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/config/api-config.ts b/frontend/src/config/api-config.ts
new file mode 100644
index 0000000..bd993ab
--- /dev/null
+++ b/frontend/src/config/api-config.ts
@@ -0,0 +1,6 @@
+export const API_CONFIG = {
+ // Use environment variable from window.__ENV (runtime) or process.env (build/server)
+ BASE_URL: (typeof window !== 'undefined' && window.__ENV?.NEXT_PUBLIC_API_BASE_URL)
+ ? window.__ENV.NEXT_PUBLIC_API_BASE_URL
+ : (process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8091/api/v1'),
+};
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
new file mode 100644
index 0000000..7a92801
--- /dev/null
+++ b/frontend/src/types/api.ts
@@ -0,0 +1,31 @@
+export interface Response {
+ code: string;
+ info: string;
+ data: T;
+}
+
+export interface AiAgentConfigResponseDTO {
+ agentId: string;
+ agentName: string;
+ agentDesc: string;
+}
+
+export interface CreateSessionRequestDTO {
+ agentId: string;
+ userId: string;
+}
+
+export interface CreateSessionResponseDTO {
+ sessionId: string;
+}
+
+export interface ChatRequestDTO {
+ agentId: string;
+ userId: string;
+ sessionId: string;
+ message: string;
+}
+
+export interface ChatResponseDTO {
+ content: string;
+}
diff --git a/frontend/src/types/env.d.ts b/frontend/src/types/env.d.ts
new file mode 100644
index 0000000..759c9bc
--- /dev/null
+++ b/frontend/src/types/env.d.ts
@@ -0,0 +1,9 @@
+declare global {
+ interface Window {
+ __ENV?: {
+ NEXT_PUBLIC_API_BASE_URL: string;
+ };
+ }
+}
+
+export {};
diff --git a/frontend/src/utils/cookie.ts b/frontend/src/utils/cookie.ts
new file mode 100644
index 0000000..2404077
--- /dev/null
+++ b/frontend/src/utils/cookie.ts
@@ -0,0 +1,46 @@
+export const COOKIE_NAME = "ai_agent_login";
+export const COOKIE_DAYS = 7;
+
+export interface UserInfo {
+ user: string;
+ ts: number;
+}
+
+export const setCookie = (name: string, value: string, days: number) => {
+ const maxAge = Math.max(0, Math.floor(days * 86400));
+ document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; SameSite=Lax`;
+};
+
+export const getCookie = (name: string): string | null => {
+ const cookies = document.cookie ? document.cookie.split("; ") : [];
+ for (const item of cookies) {
+ const eqIndex = item.indexOf("=");
+ const k = eqIndex >= 0 ? item.slice(0, eqIndex) : item;
+ const v = eqIndex >= 0 ? item.slice(eqIndex + 1) : "";
+ if (k === name) return decodeURIComponent(v);
+ }
+ return null;
+};
+
+export const deleteCookie = (name: string) => {
+ document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
+};
+
+export const getUserInfo = (): UserInfo | null => {
+ const raw = getCookie(COOKIE_NAME);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+};
+
+export const setUserInfo = (user: string) => {
+ const payload: UserInfo = { user, ts: Date.now() };
+ setCookie(COOKIE_NAME, JSON.stringify(payload), COOKIE_DAYS);
+};
+
+export const clearUserInfo = () => {
+ deleteCookie(COOKIE_NAME);
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..cf9c65d
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..33120f9
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,94 @@
+module ai-agent-scaffold-go
+
+go 1.25.6
+
+require (
+ github.com/cloudwego/eino v0.9.2
+ github.com/gin-gonic/gin v1.12.0
+ github.com/redis/go-redis/v9 v9.20.0
+ go.uber.org/zap v1.28.0
+ google.golang.org/adk v1.4.0
+ gopkg.in/yaml.v3 v3.0.1
+ gorm.io/driver/mysql v1.6.0
+ gorm.io/gorm v1.31.1
+)
+
+require (
+ cloud.google.com/go v0.123.0 // indirect
+ cloud.google.com/go/auth v0.20.0 // indirect
+ cloud.google.com/go/compute/metadata v0.9.0 // indirect
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.15.0 // indirect
+ github.com/bytedance/sonic/loader v0.5.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/eino-contrib/jsonschema v1.0.3 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.30.1 // indirect
+ github.com/go-sql-driver/mysql v1.8.1 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/jsonschema-go v0.4.2 // indirect
+ github.com/google/s2a-go v0.1.9 // indirect
+ github.com/google/safehtml v0.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
+ github.com/googleapis/gax-go/v2 v2.22.0 // indirect
+ github.com/goph/emperror v0.17.2 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/nikolalohinski/gonja v1.5.3 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/quic-go/quic-go v0.59.0 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.1 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
+ github.com/yargevad/filepathx v1.0.0 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/log v0.16.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.10.0 // indirect
+ golang.org/x/arch v0.22.0 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
+ golang.org/x/net v0.54.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ google.golang.org/api v0.279.0 // indirect
+ google.golang.org/genai v1.57.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
+ google.golang.org/grpc v1.81.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ rsc.io/omap v1.2.0 // indirect
+ rsc.io/ordered v1.1.1 // indirect
+)
diff --git a/internal/api/doc.go b/internal/api/doc.go
new file mode 100644
index 0000000..32ce0ae
--- /dev/null
+++ b/internal/api/doc.go
@@ -0,0 +1,2 @@
+// Package api contains request and response contracts exposed by triggers.
+package api
diff --git a/internal/api/dto/agent.go b/internal/api/dto/agent.go
new file mode 100644
index 0000000..00df464
--- /dev/null
+++ b/internal/api/dto/agent.go
@@ -0,0 +1,27 @@
+package dto
+
+type AiAgentConfigResponse struct {
+ AgentID string `json:"agentId"`
+ AgentName string `json:"agentName"`
+ AgentDesc string `json:"agentDesc"`
+}
+
+type CreateSessionRequest struct {
+ AgentID string `json:"agentId"`
+ UserID string `json:"userId"`
+}
+
+type CreateSessionResponse struct {
+ SessionID string `json:"sessionId"`
+}
+
+type ChatRequest struct {
+ AgentID string `json:"agentId"`
+ UserID string `json:"userId"`
+ SessionID string `json:"sessionId"`
+ Message string `json:"message"`
+}
+
+type ChatResponse struct {
+ Content string `json:"content"`
+}
diff --git a/internal/api/response/response.go b/internal/api/response/response.go
new file mode 100644
index 0000000..0604b8b
--- /dev/null
+++ b/internal/api/response/response.go
@@ -0,0 +1,24 @@
+package response
+
+import "ai-agent-scaffold-go/pkg/types"
+
+type Envelope[T any] struct {
+ Code string `json:"code"`
+ Info string `json:"info"`
+ Data T `json:"data,omitempty"`
+}
+
+func Success[T any](data T) Envelope[T] {
+ return Envelope[T]{
+ Code: types.CodeSuccess,
+ Info: types.InfoSuccess,
+ Data: data,
+ }
+}
+
+func Failure(code, info string) Envelope[any] {
+ return Envelope[any]{
+ Code: code,
+ Info: info,
+ }
+}
diff --git a/internal/app/bootstrap/bootstrap.go b/internal/app/bootstrap/bootstrap.go
new file mode 100644
index 0000000..7ee346a
--- /dev/null
+++ b/internal/app/bootstrap/bootstrap.go
@@ -0,0 +1,150 @@
+// Package bootstrap wires application config, armory assembly, chat service,
+// and Gin HTTP routes into a single runnable engine.
+package bootstrap
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+
+ "ai-agent-scaffold-go/internal/app/config"
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "ai-agent-scaffold-go/internal/domain/agent/service/armory"
+ "ai-agent-scaffold-go/internal/domain/agent/service/armory/factory"
+ "ai-agent-scaffold-go/internal/domain/agent/service/chat"
+ "ai-agent-scaffold-go/internal/infrastructure/adk"
+ "ai-agent-scaffold-go/internal/infrastructure/ai"
+ httptrigger "ai-agent-scaffold-go/internal/trigger/http"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type Engine struct {
+ Application config.Application
+ Router *gin.Engine
+ ChatService *chat.Service
+ Registry ports.AgentRegistry
+ Sessions ports.SessionStore
+ Logger *zap.Logger
+}
+
+func Build(ctx context.Context, appCfg config.Application, logger *zap.Logger) (*Engine, error) {
+ tables, err := loadAgentTables(appCfg.Agent.ConfigPaths)
+ if err != nil {
+ return nil, err
+ }
+ if len(tables) == 0 {
+ return nil, fmt.Errorf("no agent tables loaded; configure agent.config-paths")
+ }
+
+ llmTimeout, err := appCfg.LLM.RequestTimeoutDuration()
+ if err != nil {
+ return nil, err
+ }
+
+ registry := ports.NewInMemoryAgentRegistry()
+ sessions := ports.NewInMemorySessionStore()
+
+ modelProvider := ai.NewEinoProvider().WithRequestTimeout(llmTimeout)
+ toolRouter := ai.NewMCPToolRouter()
+ mcpFactory := ai.NewToolFactory(toolRouter)
+ skillFactory := ai.NewSkillFactory()
+ agentFactory := adk.NewFactory()
+ agentFactory.UseToolRouter(toolRouter)
+ runnerFactory := agentFactory
+
+ armoryFactory := factory.NewDefaultFactory(modelProvider, mcpFactory, skillFactory, agentFactory, runnerFactory, registry)
+ armoryService := armory.NewService(armoryFactory.ArmoryStrategyHandler())
+
+ if err := armoryService.AcceptArmoryAgents(ctx, tables); err != nil {
+ return nil, fmt.Errorf("armory assembly: %w", err)
+ }
+
+ chatService := chat.NewService(registry, sessions)
+
+ router := newRouter(appCfg.App.Env)
+ httptrigger.RegisterAgentRoutes(router, chatService)
+
+ if logger != nil {
+ logger.Info("agents registered", zap.Int("count", len(registry.List())), zap.Duration("llm_request_timeout", llmTimeout))
+ }
+
+ return &Engine{
+ Application: appCfg,
+ Router: router,
+ ChatService: chatService,
+ Registry: registry,
+ Sessions: sessions,
+ Logger: logger,
+ }, nil
+}
+
+func (e *Engine) Run() error {
+ addr := e.Application.Server.Addr
+ if addr == "" {
+ addr = ":8091"
+ }
+ if e.Logger != nil {
+ e.Logger.Info("http server listening", zap.String("addr", addr))
+ }
+ return e.Router.Run(addr)
+}
+
+func newRouter(env string) *gin.Engine {
+ if env == "prod" || env == "production" {
+ gin.SetMode(gin.ReleaseMode)
+ }
+ router := gin.New()
+ router.Use(gin.Recovery())
+ if env != "prod" && env != "production" {
+ router.Use(gin.Logger())
+ }
+ router.Use(corsMiddleware())
+ router.GET("/healthz", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ })
+ return router
+}
+
+func corsMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ origin := c.GetHeader("Origin")
+ if origin != "" {
+ c.Header("Access-Control-Allow-Origin", origin)
+ c.Header("Vary", "Origin")
+ } else {
+ c.Header("Access-Control-Allow-Origin", "*")
+ }
+ c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ c.Header("Access-Control-Allow-Credentials", "true")
+ if c.Request.Method == http.MethodOptions {
+ c.AbortWithStatus(http.StatusNoContent)
+ return
+ }
+ c.Next()
+ }
+}
+
+func loadAgentTables(paths []string) (map[string]model.AiAgentConfigTable, error) {
+ merged := make(map[string]model.AiAgentConfigTable)
+ for _, raw := range paths {
+ path := strings.TrimSpace(raw)
+ if path == "" {
+ continue
+ }
+ expanded := os.ExpandEnv(path)
+ tables, err := config.LoadAgentTablesFile(expanded)
+ if err != nil {
+ return nil, err
+ }
+ for name, table := range tables {
+ merged[name] = table
+ }
+ }
+ return merged, nil
+}
diff --git a/internal/app/config/application.go b/internal/app/config/application.go
new file mode 100644
index 0000000..6b89f13
--- /dev/null
+++ b/internal/app/config/application.go
@@ -0,0 +1,88 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Application struct {
+ App AppSection `yaml:"app"`
+ Server ServerSection `yaml:"server"`
+ Database DatabaseSection `yaml:"database"`
+ Redis RedisSection `yaml:"redis"`
+ Agent AgentSection `yaml:"agent"`
+ LLM LLMSection `yaml:"llm"`
+}
+
+type AppSection struct {
+ Name string `yaml:"name"`
+ Env string `yaml:"env"`
+}
+
+type ServerSection struct {
+ Addr string `yaml:"addr"`
+}
+
+type DatabaseSection struct {
+ Required bool `yaml:"required"`
+ DSN string `yaml:"dsn"`
+}
+
+type RedisSection struct {
+ Required bool `yaml:"required"`
+ Addr string `yaml:"addr"`
+ Password string `yaml:"password"`
+ DB int `yaml:"db"`
+}
+
+type AgentSection struct {
+ ConfigPaths []string `yaml:"config-paths"`
+}
+
+type LLMSection struct {
+ RequestTimeout string `yaml:"request-timeout"`
+}
+
+const defaultLLMRequestTimeout = 5 * time.Minute
+
+func (s LLMSection) RequestTimeoutDuration() (time.Duration, error) {
+ raw := ""
+ if s.RequestTimeout != "" {
+ raw = s.RequestTimeout
+ }
+ if raw == "" {
+ return defaultLLMRequestTimeout, nil
+ }
+ d, err := time.ParseDuration(raw)
+ if err != nil {
+ return 0, fmt.Errorf("invalid llm.request-timeout %q: %w", raw, err)
+ }
+ if d <= 0 {
+ return 0, fmt.Errorf("llm.request-timeout must be positive, got %q", raw)
+ }
+ return d, nil
+}
+
+func LoadApplication(path string) (Application, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return Application{}, fmt.Errorf("read application config %s: %w", path, err)
+ }
+ var app Application
+ if err := yaml.Unmarshal(data, &app); err != nil {
+ return Application{}, fmt.Errorf("parse application config: %w", err)
+ }
+ if app.Server.Addr == "" {
+ app.Server.Addr = ":8091"
+ }
+ if app.App.Env == "" {
+ app.App.Env = "local"
+ }
+ if _, err := app.LLM.RequestTimeoutDuration(); err != nil {
+ return Application{}, err
+ }
+ return app, nil
+}
diff --git a/internal/app/config/loader.go b/internal/app/config/loader.go
new file mode 100644
index 0000000..74a710c
--- /dev/null
+++ b/internal/app/config/loader.go
@@ -0,0 +1,186 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "regexp"
+ "strings"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "gopkg.in/yaml.v3"
+)
+
+type agentRoot struct {
+ AI struct {
+ Agent struct {
+ Config struct {
+ Tables map[string]model.AiAgentConfigTable `yaml:"tables"`
+ } `yaml:"config"`
+ } `yaml:"agent"`
+ } `yaml:"ai"`
+}
+
+func LoadAgentTables(data []byte) (map[string]model.AiAgentConfigTable, error) {
+ expanded := expandEnvPlaceholders(string(data))
+ var root agentRoot
+ if err := yaml.Unmarshal([]byte(expanded), &root); err != nil {
+ return nil, fmt.Errorf("parse agent config: %w", err)
+ }
+
+ tables := root.AI.Agent.Config.Tables
+ if len(tables) == 0 {
+ return nil, fmt.Errorf("agent config tables are required")
+ }
+
+ for name, table := range tables {
+ normalizeDefaults(&table)
+ if err := validateTable(name, table); err != nil {
+ return nil, err
+ }
+ tables[name] = table
+ }
+
+ return tables, nil
+}
+
+func LoadAgentTablesFile(path string) (map[string]model.AiAgentConfigTable, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read agent config %s: %w", path, err)
+ }
+ return LoadAgentTables(data)
+}
+
+var envPlaceholderRE = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`)
+
+// expandEnvPlaceholders replaces ${VAR} and ${VAR:-default} in YAML text using
+// the current process environment. It deliberately ignores bare $NAME forms so
+// dollar signs that happen to appear inside instructions or URLs are not
+// mangled.
+func expandEnvPlaceholders(input string) string {
+ return envPlaceholderRE.ReplaceAllStringFunc(input, func(match string) string {
+ groups := envPlaceholderRE.FindStringSubmatch(match)
+ name := groups[1]
+ if value, ok := os.LookupEnv(name); ok && value != "" {
+ return value
+ }
+ if len(groups) > 2 {
+ return groups[2]
+ }
+ return ""
+ })
+}
+
+func normalizeDefaults(table *model.AiAgentConfigTable) {
+ if table.Module.AiAPI.CompletionsPath == "" {
+ table.Module.AiAPI.CompletionsPath = "v1/chat/completions"
+ }
+ if table.Module.AiAPI.EmbeddingsPath == "" {
+ table.Module.AiAPI.EmbeddingsPath = "v1/embeddings"
+ }
+ for i := range table.Module.ChatModel.ToolSkillsList {
+ if table.Module.ChatModel.ToolSkillsList[i].Type == "" {
+ table.Module.ChatModel.ToolSkillsList[i].Type = "directory"
+ }
+ }
+ for i := range table.Module.AgentWorkflows {
+ if table.Module.AgentWorkflows[i].MaxIterations == 0 {
+ table.Module.AgentWorkflows[i].MaxIterations = 3
+ }
+ }
+}
+
+func validateTable(name string, table model.AiAgentConfigTable) error {
+ prefix := "agent table " + name
+ required := map[string]string{
+ "app-name": table.AppName,
+ "agent.agent-id": table.Agent.AgentID,
+ "module.ai-api.base-url": table.Module.AiAPI.BaseURL,
+ "module.ai-api.api-key": table.Module.AiAPI.APIKey,
+ "module.chat-model.model": table.Module.ChatModel.Model,
+ "module.runner.agent-name": table.Module.Runner.AgentName,
+ }
+ for field, value := range required {
+ if strings.TrimSpace(value) == "" {
+ return fmt.Errorf("%s: %s is required", prefix, field)
+ }
+ }
+ if len(table.Module.Agents) == 0 {
+ return fmt.Errorf("%s: module.agents is required", prefix)
+ }
+ for i, agent := range table.Module.Agents {
+ if strings.TrimSpace(agent.Name) == "" {
+ return fmt.Errorf("%s: module.agents[%d].name is required", prefix, i)
+ }
+ if strings.TrimSpace(agent.Instruction) == "" {
+ return fmt.Errorf("%s: module.agents[%d].instruction is required", prefix, i)
+ }
+ }
+ for i, workflow := range table.Module.AgentWorkflows {
+ switch workflow.Type {
+ case model.WorkflowTypeLoop, model.WorkflowTypeParallel, model.WorkflowTypeSequential:
+ default:
+ return fmt.Errorf("%s: module.agent-workflows[%d].type is invalid: %s", prefix, i, workflow.Type)
+ }
+ if strings.TrimSpace(workflow.Name) == "" {
+ return fmt.Errorf("%s: module.agent-workflows[%d].name is required", prefix, i)
+ }
+ }
+ for i, tool := range table.Module.ChatModel.ToolMCPList {
+ if err := validateMCPEntry(prefix, i, tool); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateMCPEntry(prefix string, index int, tool model.ToolMCPConfig) error {
+ fieldPrefix := fmt.Sprintf("%s: module.chat-model.tool-mcp-list[%d]", prefix, index)
+ count := 0
+ if tool.Local != nil {
+ count++
+ }
+ if tool.SSE != nil {
+ count++
+ }
+ if tool.Stdio != nil {
+ count++
+ }
+
+ switch count {
+ case 0:
+ return fmt.Errorf("%s must define exactly one of local, sse, or stdio", fieldPrefix)
+ case 1:
+ default:
+ return fmt.Errorf("%s cannot define multiple transport types", fieldPrefix)
+ }
+
+ if tool.Local != nil {
+ if strings.TrimSpace(tool.Local.Name) == "" {
+ return fmt.Errorf("%s.local.name is required", fieldPrefix)
+ }
+ }
+ if tool.SSE != nil {
+ if strings.TrimSpace(tool.SSE.Name) == "" {
+ return fmt.Errorf("%s.sse.name is required", fieldPrefix)
+ }
+ if strings.TrimSpace(tool.SSE.BaseURI) == "" {
+ return fmt.Errorf("%s.sse.base-uri is required", fieldPrefix)
+ }
+ if tool.SSE.RequestTimeout < 0 {
+ return fmt.Errorf("%s.sse.request-timeout must be positive", fieldPrefix)
+ }
+ }
+ if tool.Stdio != nil {
+ if strings.TrimSpace(tool.Stdio.Name) == "" {
+ return fmt.Errorf("%s.stdio.name is required", fieldPrefix)
+ }
+ if strings.TrimSpace(tool.Stdio.ServerParameters.Command) == "" {
+ return fmt.Errorf("%s.stdio.server-parameters.command is required", fieldPrefix)
+ }
+ if tool.Stdio.RequestTimeout < 0 {
+ return fmt.Errorf("%s.stdio.request-timeout must be positive", fieldPrefix)
+ }
+ }
+ return nil
+}
diff --git a/internal/app/doc.go b/internal/app/doc.go
new file mode 100644
index 0000000..3f3a89d
--- /dev/null
+++ b/internal/app/doc.go
@@ -0,0 +1,2 @@
+// Package app wires configuration, application services, and infrastructure.
+package app
diff --git a/internal/domain/agent/model/command.go b/internal/domain/agent/model/command.go
new file mode 100644
index 0000000..322cb23
--- /dev/null
+++ b/internal/domain/agent/model/command.go
@@ -0,0 +1,47 @@
+package model
+
+type ArmoryCommand struct {
+ Table AiAgentConfigTable
+}
+
+type ChatCommand struct {
+ AgentID string
+ UserID string
+ SessionID string
+ Message string
+ Content ChatContent
+}
+
+type ChatContent struct {
+ Texts []TextPart
+ Files []FilePart
+ InlineDatas []InlineDataPart
+}
+
+type TextPart struct {
+ Message string
+}
+
+type FilePart struct {
+ FileURI string
+ MimeType string
+}
+
+type InlineDataPart struct {
+ Bytes []byte
+ MimeType string
+}
+
+type RegisteredAgent struct {
+ AppName string
+ AgentID string
+ AgentName string
+ AgentDesc string
+ Runner Runner
+}
+
+type Runner interface {
+ CreateSession(userID string) (string, error)
+ Run(userID, sessionID string, content ChatContent) ([]string, error)
+ Stream(userID, sessionID string, content ChatContent) (<-chan string, <-chan error)
+}
diff --git a/internal/domain/agent/model/config.go b/internal/domain/agent/model/config.go
new file mode 100644
index 0000000..48f1078
--- /dev/null
+++ b/internal/domain/agent/model/config.go
@@ -0,0 +1,96 @@
+package model
+
+type WorkflowType string
+
+const (
+ WorkflowTypeLoop WorkflowType = "loop"
+ WorkflowTypeParallel WorkflowType = "parallel"
+ WorkflowTypeSequential WorkflowType = "sequential"
+)
+
+type AiAgentConfigTable struct {
+ AppName string `yaml:"app-name" json:"appName"`
+ Agent AgentSummary `yaml:"agent" json:"agent"`
+ Module AgentModule `yaml:"module" json:"module"`
+}
+
+type AgentSummary struct {
+ AgentID string `yaml:"agent-id" json:"agentId"`
+ AgentName string `yaml:"agent-name" json:"agentName"`
+ AgentDesc string `yaml:"agent-desc" json:"agentDesc"`
+}
+
+type AgentModule struct {
+ AiAPI AiAPIConfig `yaml:"ai-api" json:"aiApi"`
+ ChatModel ChatModelConfig `yaml:"chat-model" json:"chatModel"`
+ Agents []AgentConfig `yaml:"agents" json:"agents"`
+ AgentWorkflows []AgentWorkflowConfig `yaml:"agent-workflows" json:"agentWorkflows"`
+ Runner RunnerConfig `yaml:"runner" json:"runner"`
+}
+
+type AiAPIConfig struct {
+ BaseURL string `yaml:"base-url" json:"baseUrl"`
+ APIKey string `yaml:"api-key" json:"apiKey"`
+ CompletionsPath string `yaml:"completions-path" json:"completionsPath"`
+ EmbeddingsPath string `yaml:"embeddings-path" json:"embeddingsPath"`
+}
+
+type ChatModelConfig struct {
+ Model string `yaml:"model" json:"model"`
+ ToolMCPList []ToolMCPConfig `yaml:"tool-mcp-list" json:"toolMcpList"`
+ ToolSkillsList []ToolSkillsConfig `yaml:"tool-skills-list" json:"toolSkillsList"`
+}
+
+type ToolMCPConfig struct {
+ SSE *SSEServerParameters `yaml:"sse,omitempty" json:"sse,omitempty"`
+ Stdio *StdioServerParameters `yaml:"stdio,omitempty" json:"stdio,omitempty"`
+ Local *LocalToolParameters `yaml:"local,omitempty" json:"local,omitempty"`
+}
+
+type SSEServerParameters struct {
+ Name string `yaml:"name" json:"name"`
+ BaseURI string `yaml:"base-uri" json:"baseUri"`
+ SSEEndpoint string `yaml:"sse-endpoint" json:"sseEndpoint"`
+ RequestTimeout int `yaml:"request-timeout" json:"requestTimeout"`
+}
+
+type StdioServerParameters struct {
+ Name string `yaml:"name" json:"name"`
+ RequestTimeout int `yaml:"request-timeout" json:"requestTimeout"`
+ ServerParameters ServerParameters `yaml:"server-parameters" json:"serverParameters"`
+}
+
+type ServerParameters struct {
+ Command string `yaml:"command" json:"command"`
+ Args []string `yaml:"args" json:"args"`
+ Env map[string]string `yaml:"env" json:"env"`
+}
+
+type LocalToolParameters struct {
+ Name string `yaml:"name" json:"name"`
+}
+
+type ToolSkillsConfig struct {
+ Type string `yaml:"type" json:"type"`
+ Path string `yaml:"path" json:"path"`
+}
+
+type AgentConfig struct {
+ Name string `yaml:"name" json:"name"`
+ Instruction string `yaml:"instruction" json:"instruction"`
+ Description string `yaml:"description" json:"description"`
+ OutputKey string `yaml:"output-key" json:"outputKey"`
+}
+
+type AgentWorkflowConfig struct {
+ Type WorkflowType `yaml:"type" json:"type"`
+ Name string `yaml:"name" json:"name"`
+ SubAgents []string `yaml:"sub-agents" json:"subAgents"`
+ Description string `yaml:"description" json:"description"`
+ MaxIterations int `yaml:"max-iterations" json:"maxIterations"`
+}
+
+type RunnerConfig struct {
+ AgentName string `yaml:"agent-name" json:"agentName"`
+ PluginNameList []string `yaml:"plugin-name-list" json:"pluginNameList"`
+}
diff --git a/internal/domain/agent/ports/ports.go b/internal/domain/agent/ports/ports.go
new file mode 100644
index 0000000..37b70e0
--- /dev/null
+++ b/internal/domain/agent/ports/ports.go
@@ -0,0 +1,167 @@
+package ports
+
+import (
+ "context"
+ "sync"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+)
+
+type ModelProvider interface {
+ NewAPI(ctx context.Context, config model.AiAPIConfig) (ModelAPI, error)
+ NewChatModel(ctx context.Context, api ModelAPI, config model.ChatModelConfig, tools []Tool) (ChatModel, error)
+}
+
+type ModelAPI interface{}
+
+type ChatRole string
+
+const (
+ ChatRoleSystem ChatRole = "system"
+ ChatRoleUser ChatRole = "user"
+ ChatRoleAssistant ChatRole = "assistant"
+ ChatRoleTool ChatRole = "tool"
+)
+
+type ChatMessage struct {
+ Role ChatRole
+ Content string
+ ToolCallID string
+ Name string
+ ToolCalls []ChatToolCall
+}
+
+type ChatToolCall struct {
+ ID string
+ Name string
+ Arguments string
+}
+
+type ChatReply struct {
+ Content string
+ ToolCalls []ChatToolCall
+}
+
+type ChatStreamEvent struct {
+ Delta string
+ ToolCalls []ChatToolCall
+ Done bool
+}
+
+type ChatModel interface {
+ Generate(ctx context.Context, messages []ChatMessage) (ChatReply, error)
+ Stream(ctx context.Context, messages []ChatMessage) (<-chan ChatStreamEvent, <-chan error)
+ Tools() []Tool
+}
+
+type Tool interface {
+ Name() string
+}
+
+type ToolDescriptor interface {
+ Description() string
+}
+
+type MCPToolFactory interface {
+ BuildTools(ctx context.Context, config model.ToolMCPConfig) ([]Tool, error)
+}
+
+type SkillFactory interface {
+ BuildTools(ctx context.Context, config model.ToolSkillsConfig) ([]Tool, error)
+}
+
+type ToolRouter interface {
+ CallTool(ctx context.Context, name, arguments string) (string, error)
+}
+
+type AgentFactory interface {
+ NewLLMAgent(ctx context.Context, config model.AgentConfig, chatModel ChatModel) (Agent, error)
+ NewLoopAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
+ NewParallelAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
+ NewSequentialAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
+}
+
+type Agent interface {
+ Name() string
+}
+
+type RunnerPlugin interface {
+ Name() string
+ OnUserMessage(ctx context.Context, appName, userID, sessionID string, agent Agent, content model.ChatContent) error
+ BeforeAgent(ctx context.Context, appName, userID, sessionID string, agent Agent) error
+}
+
+type RunnerFactory interface {
+ NewRunner(ctx context.Context, appName string, agent Agent, pluginNames []string) (model.Runner, error)
+}
+
+type AgentRegistry interface {
+ Register(agent model.RegisteredAgent) error
+ Get(agentID string) (model.RegisteredAgent, bool)
+ List() []model.RegisteredAgent
+}
+
+type SessionStore interface {
+ Get(userID, agentID string) (string, bool)
+ Set(userID, agentID, sessionID string) error
+}
+
+type InMemoryAgentRegistry struct {
+ mu sync.RWMutex
+ agents map[string]model.RegisteredAgent
+}
+
+func NewInMemoryAgentRegistry() *InMemoryAgentRegistry {
+ return &InMemoryAgentRegistry{agents: make(map[string]model.RegisteredAgent)}
+}
+
+func (r *InMemoryAgentRegistry) Register(agent model.RegisteredAgent) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.agents[agent.AgentID] = agent
+ return nil
+}
+
+func (r *InMemoryAgentRegistry) Get(agentID string) (model.RegisteredAgent, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ agent, ok := r.agents[agentID]
+ return agent, ok
+}
+
+func (r *InMemoryAgentRegistry) List() []model.RegisteredAgent {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ agents := make([]model.RegisteredAgent, 0, len(r.agents))
+ for _, agent := range r.agents {
+ agents = append(agents, agent)
+ }
+ return agents
+}
+
+type InMemorySessionStore struct {
+ mu sync.RWMutex
+ sessions map[string]string
+}
+
+func NewInMemorySessionStore() *InMemorySessionStore {
+ return &InMemorySessionStore{sessions: make(map[string]string)}
+}
+
+func (s *InMemorySessionStore) Get(userID, agentID string) (string, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ sessionID, ok := s.sessions[sessionKey(userID, agentID)]
+ return sessionID, ok
+}
+
+func (s *InMemorySessionStore) Set(userID, agentID, sessionID string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sessions[sessionKey(userID, agentID)] = sessionID
+ return nil
+}
+
+func sessionKey(userID, agentID string) string {
+ return userID + ":" + agentID
+}
diff --git a/internal/domain/agent/service/armory/agent_node.go b/internal/domain/agent/service/armory/agent_node.go
new file mode 100644
index 0000000..99319fe
--- /dev/null
+++ b/internal/domain/agent/service/armory/agent_node.go
@@ -0,0 +1,29 @@
+package armory
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "ai-agent-scaffold-go/internal/domain/shared/tree"
+)
+
+type AgentNode struct {
+ agentFactory ports.AgentFactory
+ next Handler
+}
+
+func NewAgentNode(agentFactory ports.AgentFactory, next Handler) AgentNode {
+ return AgentNode{agentFactory: agentFactory, next: next}
+}
+
+func (n AgentNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
+ for _, config := range command.Table.Module.Agents {
+ agent, err := n.agentFactory.NewLLMAgent(ctx, config, dynamic.ChatModel)
+ if err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ dynamic.AddAgent(agent)
+ }
+ return tree.Route(ctx, n.next, command, dynamic)
+}
diff --git a/internal/domain/agent/service/armory/ai_api_node.go b/internal/domain/agent/service/armory/ai_api_node.go
new file mode 100644
index 0000000..f642f62
--- /dev/null
+++ b/internal/domain/agent/service/armory/ai_api_node.go
@@ -0,0 +1,27 @@
+package armory
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "ai-agent-scaffold-go/internal/domain/shared/tree"
+)
+
+type AiAPINode struct {
+ modelProvider ports.ModelProvider
+ next Handler
+}
+
+func NewAiAPINode(modelProvider ports.ModelProvider, next Handler) AiAPINode {
+ return AiAPINode{modelProvider: modelProvider, next: next}
+}
+
+func (n AiAPINode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
+ api, err := n.modelProvider.NewAPI(ctx, command.Table.Module.AiAPI)
+ if err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ dynamic.ModelAPI = api
+ return tree.Route(ctx, n.next, command, dynamic)
+}
diff --git a/internal/domain/agent/service/armory/chat_model_node.go b/internal/domain/agent/service/armory/chat_model_node.go
new file mode 100644
index 0000000..2ff46ee
--- /dev/null
+++ b/internal/domain/agent/service/armory/chat_model_node.go
@@ -0,0 +1,54 @@
+package armory
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "ai-agent-scaffold-go/internal/domain/shared/tree"
+)
+
+type ChatModelNode struct {
+ modelProvider ports.ModelProvider
+ mcpFactory ports.MCPToolFactory
+ skillFactory ports.SkillFactory
+ next Handler
+}
+
+func NewChatModelNode(
+ modelProvider ports.ModelProvider,
+ mcpFactory ports.MCPToolFactory,
+ skillFactory ports.SkillFactory,
+ next Handler,
+) ChatModelNode {
+ return ChatModelNode{
+ modelProvider: modelProvider,
+ mcpFactory: mcpFactory,
+ skillFactory: skillFactory,
+ next: next,
+ }
+}
+
+func (n ChatModelNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
+ var tools []ports.Tool
+ for _, config := range command.Table.Module.ChatModel.ToolMCPList {
+ built, err := n.mcpFactory.BuildTools(ctx, config)
+ if err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ tools = append(tools, built...)
+ }
+ for _, config := range command.Table.Module.ChatModel.ToolSkillsList {
+ built, err := n.skillFactory.BuildTools(ctx, config)
+ if err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ tools = append(tools, built...)
+ }
+ chatModel, err := n.modelProvider.NewChatModel(ctx, dynamic.ModelAPI, command.Table.Module.ChatModel, tools)
+ if err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ dynamic.ChatModel = chatModel
+ return tree.Route(ctx, n.next, command, dynamic)
+}
diff --git a/internal/domain/agent/service/armory/context.go b/internal/domain/agent/service/armory/context.go
new file mode 100644
index 0000000..ae8a866
--- /dev/null
+++ b/internal/domain/agent/service/armory/context.go
@@ -0,0 +1,87 @@
+package armory
+
+import (
+ "sync"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+type DynamicContext struct {
+ mu sync.RWMutex
+ ModelAPI ports.ModelAPI
+ ChatModel ports.ChatModel
+ agentGroup map[string]ports.Agent
+ currentStepIndex int
+ currentWorkflow *model.AgentWorkflowConfig
+ values map[string]any
+}
+
+func NewDynamicContext() *DynamicContext {
+ return &DynamicContext{
+ agentGroup: make(map[string]ports.Agent),
+ values: make(map[string]any),
+ }
+}
+
+func (c *DynamicContext) AddAgent(agent ports.Agent) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.agentGroup[agent.Name()] = agent
+}
+
+func (c *DynamicContext) Agent(name string) (ports.Agent, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ agent, ok := c.agentGroup[name]
+ return agent, ok
+}
+
+func (c *DynamicContext) QueryAgentList(names []string) []ports.Agent {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ agents := make([]ports.Agent, 0, len(names))
+ for _, name := range names {
+ if agent, ok := c.agentGroup[name]; ok {
+ agents = append(agents, agent)
+ }
+ }
+ return agents
+}
+
+func (c *DynamicContext) AddCurrentStepIndex() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.currentStepIndex++
+}
+
+func (c *DynamicContext) CurrentStepIndex() int {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.currentStepIndex
+}
+
+func (c *DynamicContext) SetCurrentWorkflow(workflow *model.AgentWorkflowConfig) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.currentWorkflow = workflow
+}
+
+func (c *DynamicContext) CurrentWorkflow() *model.AgentWorkflowConfig {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.currentWorkflow
+}
+
+func (c *DynamicContext) SetValue(key string, value any) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.values[key] = value
+}
+
+func (c *DynamicContext) Value(key string) (any, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ value, ok := c.values[key]
+ return value, ok
+}
diff --git a/internal/domain/agent/service/armory/factory/factory.go b/internal/domain/agent/service/armory/factory/factory.go
new file mode 100644
index 0000000..42723b6
--- /dev/null
+++ b/internal/domain/agent/service/armory/factory/factory.go
@@ -0,0 +1,32 @@
+package factory
+
+import (
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "ai-agent-scaffold-go/internal/domain/agent/service/armory"
+ "ai-agent-scaffold-go/internal/domain/agent/service/armory/workflow"
+)
+
+type DefaultFactory struct {
+ root armory.Handler
+}
+
+func NewDefaultFactory(
+ modelProvider ports.ModelProvider,
+ mcpFactory ports.MCPToolFactory,
+ skillFactory ports.SkillFactory,
+ agentFactory ports.AgentFactory,
+ runnerFactory ports.RunnerFactory,
+ registry ports.AgentRegistry,
+) *DefaultFactory {
+ runner := armory.NewRunnerNode(runnerFactory, registry)
+ workflowNode := workflow.NewAgentWorkflowNode(agentFactory, runner)
+ agent := armory.NewAgentNode(agentFactory, workflowNode)
+ chatModel := armory.NewChatModelNode(modelProvider, mcpFactory, skillFactory, agent)
+ api := armory.NewAiAPINode(modelProvider, chatModel)
+ root := armory.NewRootNode(api)
+ return &DefaultFactory{root: root}
+}
+
+func (f *DefaultFactory) ArmoryStrategyHandler() armory.Handler {
+ return f.root
+}
diff --git a/internal/domain/agent/service/armory/root_node.go b/internal/domain/agent/service/armory/root_node.go
new file mode 100644
index 0000000..bcf0661
--- /dev/null
+++ b/internal/domain/agent/service/armory/root_node.go
@@ -0,0 +1,20 @@
+package armory
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/shared/tree"
+)
+
+type RootNode struct {
+ next Handler
+}
+
+func NewRootNode(next Handler) RootNode {
+ return RootNode{next: next}
+}
+
+func (n RootNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
+ return tree.Route(ctx, n.next, command, dynamic)
+}
diff --git a/internal/domain/agent/service/armory/runner_node.go b/internal/domain/agent/service/armory/runner_node.go
new file mode 100644
index 0000000..601dbcf
--- /dev/null
+++ b/internal/domain/agent/service/armory/runner_node.go
@@ -0,0 +1,44 @@
+package armory
+
+import (
+ "context"
+ "fmt"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+type RunnerNode struct {
+ runnerFactory ports.RunnerFactory
+ registry ports.AgentRegistry
+}
+
+func NewRunnerNode(runnerFactory ports.RunnerFactory, registry ports.AgentRegistry) RunnerNode {
+ return RunnerNode{runnerFactory: runnerFactory, registry: registry}
+}
+
+func (n RunnerNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
+ runnerConfig := command.Table.Module.Runner
+ if runnerConfig.AgentName == "" {
+ return model.RegisteredAgent{}, fmt.Errorf("runner.agent-name is required")
+ }
+ agent, ok := dynamic.Agent(runnerConfig.AgentName)
+ if !ok {
+ return model.RegisteredAgent{}, fmt.Errorf("runner agent %q not found", runnerConfig.AgentName)
+ }
+ runner, err := n.runnerFactory.NewRunner(ctx, command.Table.AppName, agent, runnerConfig.PluginNameList)
+ if err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ registered := model.RegisteredAgent{
+ AppName: command.Table.AppName,
+ AgentID: command.Table.Agent.AgentID,
+ AgentName: command.Table.Agent.AgentName,
+ AgentDesc: command.Table.Agent.AgentDesc,
+ Runner: runner,
+ }
+ if err := n.registry.Register(registered); err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ return registered, nil
+}
diff --git a/internal/domain/agent/service/armory/service.go b/internal/domain/agent/service/armory/service.go
new file mode 100644
index 0000000..1929b9b
--- /dev/null
+++ b/internal/domain/agent/service/armory/service.go
@@ -0,0 +1,25 @@
+package armory
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+)
+
+type Service struct {
+ handler Handler
+}
+
+func NewService(handler Handler) *Service {
+ return &Service{handler: handler}
+}
+
+func (s *Service) AcceptArmoryAgents(ctx context.Context, tables map[string]model.AiAgentConfigTable) error {
+ for _, table := range tables {
+ _, err := s.handler.Apply(ctx, model.ArmoryCommand{Table: table}, NewDynamicContext())
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/domain/agent/service/armory/support.go b/internal/domain/agent/service/armory/support.go
new file mode 100644
index 0000000..0e5821e
--- /dev/null
+++ b/internal/domain/agent/service/armory/support.go
@@ -0,0 +1,8 @@
+package armory
+
+import (
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/shared/tree"
+)
+
+type Handler = tree.Handler[model.ArmoryCommand, *DynamicContext, model.RegisteredAgent]
diff --git a/internal/domain/agent/service/armory/workflow/agent_workflow_node.go b/internal/domain/agent/service/armory/workflow/agent_workflow_node.go
new file mode 100644
index 0000000..3909da8
--- /dev/null
+++ b/internal/domain/agent/service/armory/workflow/agent_workflow_node.go
@@ -0,0 +1,67 @@
+package workflow
+
+import (
+ "context"
+ "fmt"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "ai-agent-scaffold-go/internal/domain/agent/service/armory"
+ "ai-agent-scaffold-go/internal/domain/shared/tree"
+)
+
+type workflowBuilder interface {
+ Build(context.Context, model.AgentWorkflowConfig, []ports.Agent) (ports.Agent, error)
+}
+
+type AgentWorkflowNode struct {
+ next armory.Handler
+ builders map[model.WorkflowType]workflowBuilder
+ buildOrder []model.WorkflowType
+}
+
+func NewAgentWorkflowNode(agentFactory ports.AgentFactory, next armory.Handler) *AgentWorkflowNode {
+ buildOrder := []model.WorkflowType{
+ model.WorkflowTypeLoop,
+ model.WorkflowTypeParallel,
+ model.WorkflowTypeSequential,
+ }
+ return &AgentWorkflowNode{
+ next: next,
+ builders: map[model.WorkflowType]workflowBuilder{
+ model.WorkflowTypeLoop: NewLoopNode(agentFactory),
+ model.WorkflowTypeParallel: NewParallelNode(agentFactory),
+ model.WorkflowTypeSequential: NewSequentialNode(agentFactory),
+ },
+ buildOrder: buildOrder,
+ }
+}
+
+func (n *AgentWorkflowNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *armory.DynamicContext) (model.RegisteredAgent, error) {
+ workflows := command.Table.Module.AgentWorkflows
+ if dynamic.CurrentStepIndex() >= len(workflows) {
+ dynamic.SetCurrentWorkflow(nil)
+ return tree.Route(ctx, n.next, command, dynamic)
+ }
+
+ workflow := workflows[dynamic.CurrentStepIndex()]
+ dynamic.SetCurrentWorkflow(&workflow)
+ dynamic.AddCurrentStepIndex()
+
+ agent, err := n.build(ctx, workflow, dynamic.QueryAgentList(workflow.SubAgents))
+ if err != nil {
+ return model.RegisteredAgent{}, err
+ }
+ dynamic.AddAgent(agent)
+ return n.Apply(ctx, command, dynamic)
+}
+
+func (n *AgentWorkflowNode) build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
+ for _, workflowType := range n.buildOrder {
+ if workflow.Type != workflowType {
+ continue
+ }
+ return n.builders[workflowType].Build(ctx, workflow, subAgents)
+ }
+ return nil, fmt.Errorf("agentWorkflow type is error: %s", workflow.Type)
+}
diff --git a/internal/domain/agent/service/armory/workflow/loop_node.go b/internal/domain/agent/service/armory/workflow/loop_node.go
new file mode 100644
index 0000000..beaf89a
--- /dev/null
+++ b/internal/domain/agent/service/armory/workflow/loop_node.go
@@ -0,0 +1,20 @@
+package workflow
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+type LoopNode struct {
+ agentFactory ports.AgentFactory
+}
+
+func NewLoopNode(agentFactory ports.AgentFactory) LoopNode {
+ return LoopNode{agentFactory: agentFactory}
+}
+
+func (n LoopNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
+ return n.agentFactory.NewLoopAgent(ctx, workflow, subAgents)
+}
diff --git a/internal/domain/agent/service/armory/workflow/parallel_node.go b/internal/domain/agent/service/armory/workflow/parallel_node.go
new file mode 100644
index 0000000..aadb479
--- /dev/null
+++ b/internal/domain/agent/service/armory/workflow/parallel_node.go
@@ -0,0 +1,20 @@
+package workflow
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+type ParallelNode struct {
+ agentFactory ports.AgentFactory
+}
+
+func NewParallelNode(agentFactory ports.AgentFactory) ParallelNode {
+ return ParallelNode{agentFactory: agentFactory}
+}
+
+func (n ParallelNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
+ return n.agentFactory.NewParallelAgent(ctx, workflow, subAgents)
+}
diff --git a/internal/domain/agent/service/armory/workflow/sequential_node.go b/internal/domain/agent/service/armory/workflow/sequential_node.go
new file mode 100644
index 0000000..ac81e32
--- /dev/null
+++ b/internal/domain/agent/service/armory/workflow/sequential_node.go
@@ -0,0 +1,20 @@
+package workflow
+
+import (
+ "context"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+type SequentialNode struct {
+ agentFactory ports.AgentFactory
+}
+
+func NewSequentialNode(agentFactory ports.AgentFactory) SequentialNode {
+ return SequentialNode{agentFactory: agentFactory}
+}
+
+func (n SequentialNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
+ return n.agentFactory.NewSequentialAgent(ctx, workflow, subAgents)
+}
diff --git a/internal/domain/agent/service/chat/service.go b/internal/domain/agent/service/chat/service.go
new file mode 100644
index 0000000..f2b4f1f
--- /dev/null
+++ b/internal/domain/agent/service/chat/service.go
@@ -0,0 +1,117 @@
+package chat
+
+import (
+ "fmt"
+ "sort"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "ai-agent-scaffold-go/pkg/types"
+)
+
+type Service struct {
+ registry ports.AgentRegistry
+ sessions ports.SessionStore
+}
+
+func NewService(registry ports.AgentRegistry, sessions ports.SessionStore) *Service {
+ return &Service{registry: registry, sessions: sessions}
+}
+
+func (s *Service) QueryAgentConfigList() []model.AgentSummary {
+ registered := s.registry.List()
+ sort.Slice(registered, func(i, j int) bool {
+ return registered[i].AgentID < registered[j].AgentID
+ })
+ agents := make([]model.AgentSummary, 0, len(registered))
+ for _, agent := range registered {
+ agents = append(agents, model.AgentSummary{
+ AgentID: agent.AgentID,
+ AgentName: agent.AgentName,
+ AgentDesc: agent.AgentDesc,
+ })
+ }
+ return agents
+}
+
+func (s *Service) CreateSession(agentID, userID string) (string, error) {
+ if sessionID, ok := s.sessions.Get(userID, agentID); ok {
+ return sessionID, nil
+ }
+ registered, ok := s.registry.Get(agentID)
+ if !ok || registered.Runner == nil {
+ return "", types.NewAppError(types.CodeAgentNotFound, types.InfoAgentNotFound)
+ }
+ sessionID, err := registered.Runner.CreateSession(userID)
+ if err != nil {
+ return "", err
+ }
+ if err := s.sessions.Set(userID, agentID, sessionID); err != nil {
+ return "", err
+ }
+ return sessionID, nil
+}
+
+func (s *Service) HandleMessage(agentID, userID, sessionID, message string) ([]string, error) {
+ content := model.ChatContent{Texts: []model.TextPart{{Message: message}}}
+ return s.HandleCommand(model.ChatCommand{
+ AgentID: agentID,
+ UserID: userID,
+ SessionID: sessionID,
+ Message: message,
+ Content: content,
+ })
+}
+
+func (s *Service) HandleCommand(command model.ChatCommand) ([]string, error) {
+ registered, sessionID, err := s.resolveRunnerSession(command)
+ if err != nil {
+ return nil, err
+ }
+ return registered.Runner.Run(command.UserID, sessionID, command.Content)
+}
+
+func (s *Service) HandleMessageStream(agentID, userID, sessionID, message string) (<-chan string, <-chan error) {
+ return s.HandleCommandStream(model.ChatCommand{
+ AgentID: agentID,
+ UserID: userID,
+ SessionID: sessionID,
+ Message: message,
+ Content: model.ChatContent{Texts: []model.TextPart{{Message: message}}},
+ })
+}
+
+func (s *Service) HandleCommandStream(command model.ChatCommand) (<-chan string, <-chan error) {
+ registered, sessionID, err := s.resolveRunnerSession(command)
+ if err != nil {
+ outputs := make(chan string)
+ errs := make(chan error, 1)
+ errs <- err
+ close(outputs)
+ close(errs)
+ return outputs, errs
+ }
+ return registered.Runner.Stream(command.UserID, sessionID, command.Content)
+}
+
+func (s *Service) resolveRunnerSession(command model.ChatCommand) (model.RegisteredAgent, string, error) {
+ registered, ok := s.registry.Get(command.AgentID)
+ if !ok || registered.Runner == nil {
+ return model.RegisteredAgent{}, "", types.NewAppError(types.CodeAgentNotFound, types.InfoAgentNotFound)
+ }
+ sessionID := command.SessionID
+ if sessionID == "" {
+ var err error
+ sessionID, err = s.CreateSession(command.AgentID, command.UserID)
+ if err != nil {
+ return model.RegisteredAgent{}, "", err
+ }
+ }
+ if len(command.Content.Texts) == 0 && command.Message != "" {
+ command.Content.Texts = []model.TextPart{{Message: command.Message}}
+ }
+ if len(command.Content.Texts) == 0 && len(command.Content.Files) == 0 && len(command.Content.InlineDatas) == 0 {
+ return model.RegisteredAgent{}, "", fmt.Errorf("chat content is required")
+ }
+ return registered, sessionID, nil
+}
diff --git a/internal/domain/doc.go b/internal/domain/doc.go
new file mode 100644
index 0000000..7054aae
--- /dev/null
+++ b/internal/domain/doc.go
@@ -0,0 +1,2 @@
+// Package domain contains Agent models, services, ports, and Armory logic.
+package domain
diff --git a/internal/domain/shared/tree/router.go b/internal/domain/shared/tree/router.go
new file mode 100644
index 0000000..496d4bd
--- /dev/null
+++ b/internal/domain/shared/tree/router.go
@@ -0,0 +1,19 @@
+package tree
+
+import "context"
+
+type Handler[C any, D any, R any] interface {
+ Apply(ctx context.Context, command C, dynamic D) (R, error)
+}
+
+func Route[C any, D any, R any](ctx context.Context, next Handler[C, D, R], command C, dynamic D) (R, error) {
+ if next == nil {
+ return zero[R](), nil
+ }
+ return next.Apply(ctx, command, dynamic)
+}
+
+func zero[T any]() T {
+ var value T
+ return value
+}
diff --git a/internal/infrastructure/adk/adapter.go b/internal/infrastructure/adk/adapter.go
new file mode 100644
index 0000000..697b878
--- /dev/null
+++ b/internal/infrastructure/adk/adapter.go
@@ -0,0 +1,467 @@
+package adk
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync/atomic"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "google.golang.org/adk/plugin"
+ "google.golang.org/adk/plugin/loggingplugin"
+)
+
+const maxToolCallIterations = 4
+
+type Factory struct {
+ sessionCounter atomic.Uint64
+ plugins map[string]func() (ports.RunnerPlugin, error)
+ router ports.ToolRouter
+}
+
+type Agent struct {
+ name string
+ kind string
+ description string
+ instruction string
+ outputKey string
+ chatModel ports.ChatModel
+ router ports.ToolRouter
+ subAgents []ports.Agent
+}
+
+type Runner struct {
+ appName string
+ agent ports.Agent
+ plugins []ports.RunnerPlugin
+ counter *atomic.Uint64
+}
+
+func NewFactory() *Factory {
+ return &Factory{plugins: defaultPlugins()}
+}
+
+func (f *Factory) UseToolRouter(router ports.ToolRouter) {
+ f.router = router
+}
+
+func (f *Factory) NewLLMAgent(_ context.Context, config model.AgentConfig, chatModel ports.ChatModel) (ports.Agent, error) {
+ if strings.TrimSpace(config.Name) == "" {
+ return nil, fmt.Errorf("agent name is required")
+ }
+ if chatModel == nil {
+ return nil, fmt.Errorf("agent %q requires a chat model", config.Name)
+ }
+ return &Agent{
+ name: config.Name,
+ kind: "llm",
+ description: config.Description,
+ instruction: config.Instruction,
+ outputKey: config.OutputKey,
+ chatModel: chatModel,
+ router: f.router,
+ }, nil
+}
+
+func (f *Factory) NewLoopAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
+ return newWorkflowAgent("loop", config, subAgents, f.router)
+}
+
+func (f *Factory) NewParallelAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
+ return newWorkflowAgent("parallel", config, subAgents, f.router)
+}
+
+func (f *Factory) NewSequentialAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
+ return newWorkflowAgent("sequential", config, subAgents, f.router)
+}
+
+func (f *Factory) NewRunner(_ context.Context, appName string, agent ports.Agent, pluginNames []string) (model.Runner, error) {
+ if strings.TrimSpace(appName) == "" {
+ return nil, fmt.Errorf("app name is required")
+ }
+ if agent == nil {
+ return nil, fmt.Errorf("agent is required")
+ }
+ plugins, err := f.resolvePlugins(pluginNames)
+ if err != nil {
+ return nil, err
+ }
+ return &Runner{appName: appName, agent: agent, plugins: plugins, counter: &f.sessionCounter}, nil
+}
+
+func newWorkflowAgent(kind string, config model.AgentWorkflowConfig, subAgents []ports.Agent, router ports.ToolRouter) (ports.Agent, error) {
+ if strings.TrimSpace(config.Name) == "" {
+ return nil, fmt.Errorf("%s agent name is required", kind)
+ }
+ return &Agent{
+ name: config.Name,
+ kind: kind,
+ description: config.Description,
+ subAgents: subAgents,
+ router: router,
+ }, nil
+}
+
+func (a *Agent) Name() string {
+ return a.name
+}
+
+func (a *Agent) Description() string {
+ return a.description
+}
+
+func (a *Agent) run(ctx context.Context, content model.ChatContent) (string, error) {
+ return a.runWithVars(ctx, content, map[string]string{})
+}
+
+func (a *Agent) runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
+ switch a.kind {
+ case "llm":
+ return a.runLLM(ctx, content, vars)
+ case "sequential":
+ return a.runSequential(ctx, content, vars)
+ case "loop", "parallel":
+ return a.runFanOut(ctx, content, vars)
+ default:
+ return "", fmt.Errorf("agent %q has unknown kind %q", a.name, a.kind)
+ }
+}
+
+func (a *Agent) stream(ctx context.Context, content model.ChatContent, out chan<- string) error {
+ switch a.kind {
+ case "llm":
+ return a.streamLLM(ctx, content, out, map[string]string{})
+ default:
+ text, err := a.run(ctx, content)
+ if err != nil {
+ return err
+ }
+ if text != "" {
+ select {
+ case out <- text:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
+ return nil
+ }
+}
+
+func (a *Agent) runLLM(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
+ messages := initialMessages(applyVars(a.instruction, vars), firstText(content))
+ for iter := 0; iter < maxToolCallIterations; iter++ {
+ reply, err := a.chatModel.Generate(ctx, messages)
+ if err != nil {
+ return "", err
+ }
+ if len(reply.ToolCalls) == 0 {
+ return reply.Content, nil
+ }
+ messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleAssistant, Content: reply.Content, ToolCalls: reply.ToolCalls})
+ toolMessages, err := a.executeToolCalls(ctx, reply.ToolCalls)
+ if err != nil {
+ return "", err
+ }
+ messages = append(messages, toolMessages...)
+ }
+ return "", fmt.Errorf("agent %q exceeded tool-call iteration limit %d", a.name, maxToolCallIterations)
+}
+
+func (a *Agent) streamLLM(ctx context.Context, content model.ChatContent, out chan<- string, vars map[string]string) error {
+ messages := initialMessages(applyVars(a.instruction, vars), firstText(content))
+ for iter := 0; iter < maxToolCallIterations; iter++ {
+ events, errs := a.chatModel.Stream(ctx, messages)
+ var (
+ finalText strings.Builder
+ toolCalls []ports.ChatToolCall
+ done bool
+ )
+ streamErr := error(nil)
+ streamLoop:
+ for {
+ select {
+ case <-ctx.Done():
+ streamErr = ctx.Err()
+ break streamLoop
+ case ev, ok := <-events:
+ if !ok {
+ break streamLoop
+ }
+ if ev.Done {
+ done = true
+ }
+ if ev.Delta != "" {
+ finalText.WriteString(ev.Delta)
+ select {
+ case out <- ev.Delta:
+ case <-ctx.Done():
+ streamErr = ctx.Err()
+ break streamLoop
+ }
+ }
+ if len(ev.ToolCalls) > 0 {
+ toolCalls = append(toolCalls, ev.ToolCalls...)
+ }
+ case err, ok := <-errs:
+ if ok && err != nil {
+ streamErr = err
+ }
+ break streamLoop
+ }
+ }
+ if streamErr != nil {
+ return streamErr
+ }
+ if len(toolCalls) == 0 {
+ if !done {
+ return fmt.Errorf("agent %q stream closed without completion", a.name)
+ }
+ return nil
+ }
+ messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleAssistant, Content: finalText.String(), ToolCalls: toolCalls})
+ toolMessages, err := a.executeToolCalls(ctx, toolCalls)
+ if err != nil {
+ return err
+ }
+ messages = append(messages, toolMessages...)
+ }
+ return fmt.Errorf("agent %q exceeded tool-call iteration limit %d", a.name, maxToolCallIterations)
+}
+
+func (a *Agent) executeToolCalls(ctx context.Context, calls []ports.ChatToolCall) ([]ports.ChatMessage, error) {
+ if a.router == nil {
+ return nil, fmt.Errorf("agent %q has no tool router but model requested tool calls", a.name)
+ }
+ out := make([]ports.ChatMessage, 0, len(calls))
+ for _, call := range calls {
+ result, err := a.router.CallTool(ctx, call.Name, call.Arguments)
+ if err != nil {
+ return nil, fmt.Errorf("tool %q: %w", call.Name, err)
+ }
+ out = append(out, ports.ChatMessage{
+ Role: ports.ChatRoleTool,
+ Content: result,
+ ToolCallID: call.ID,
+ Name: call.Name,
+ })
+ }
+ return out, nil
+}
+
+func (a *Agent) runSequential(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
+ scope := cloneVars(vars)
+ var last string
+ for _, sub := range a.subAgents {
+ impl, ok := sub.(*Agent)
+ if !ok {
+ return "", fmt.Errorf("sub-agent %q is not a runnable agent", sub.Name())
+ }
+ text, err := impl.runWithVars(ctx, content, scope)
+ if err != nil {
+ return "", err
+ }
+ last = text
+ if key := strings.TrimSpace(impl.outputKey); key != "" {
+ scope[key] = text
+ }
+ }
+ return last, nil
+}
+
+func (a *Agent) runFanOut(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
+ parts := make([]string, 0, len(a.subAgents))
+ for _, sub := range a.subAgents {
+ impl, ok := sub.(*Agent)
+ if !ok {
+ return "", fmt.Errorf("sub-agent %q is not a runnable agent", sub.Name())
+ }
+ text, err := impl.runWithVars(ctx, content, vars)
+ if err != nil {
+ return "", err
+ }
+ parts = append(parts, fmt.Sprintf("[%s] %s", sub.Name(), text))
+ }
+ return strings.Join(parts, "\n"), nil
+}
+
+func cloneVars(vars map[string]string) map[string]string {
+ out := make(map[string]string, len(vars)+4)
+ for k, v := range vars {
+ out[k] = v
+ }
+ return out
+}
+
+func applyVars(template string, vars map[string]string) string {
+ if template == "" || len(vars) == 0 {
+ return template
+ }
+ out := template
+ for k, v := range vars {
+ out = strings.ReplaceAll(out, "{"+k+"}", v)
+ }
+ return out
+}
+
+func initialMessages(instruction, userText string) []ports.ChatMessage {
+ messages := make([]ports.ChatMessage, 0, 2)
+ if strings.TrimSpace(instruction) != "" {
+ messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleSystem, Content: instruction})
+ }
+ messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleUser, Content: userText})
+ return messages
+}
+
+func (r *Runner) CreateSession(userID string) (string, error) {
+ if strings.TrimSpace(userID) == "" {
+ return "", fmt.Errorf("user id is required")
+ }
+ next := r.counter.Add(1)
+ return fmt.Sprintf("%s:%s:%d", r.appName, userID, next), nil
+}
+
+func (r *Runner) Run(userID, sessionID string, content model.ChatContent) ([]string, error) {
+ if strings.TrimSpace(userID) == "" || strings.TrimSpace(sessionID) == "" {
+ return nil, fmt.Errorf("user id and session id are required")
+ }
+ if err := r.notifyPlugins(userID, sessionID, content); err != nil {
+ return nil, err
+ }
+ impl, ok := r.agent.(*Agent)
+ if !ok {
+ return nil, fmt.Errorf("runner agent %q is not runnable", r.agent.Name())
+ }
+ output, err := impl.run(context.Background(), content)
+ if err != nil {
+ return nil, err
+ }
+ if output == "" {
+ return []string{}, nil
+ }
+ return []string{output}, nil
+}
+
+func (r *Runner) Stream(userID, sessionID string, content model.ChatContent) (<-chan string, <-chan error) {
+ outputs := make(chan string, 8)
+ errs := make(chan error, 1)
+ go func() {
+ defer close(outputs)
+ defer close(errs)
+ if strings.TrimSpace(userID) == "" || strings.TrimSpace(sessionID) == "" {
+ errs <- fmt.Errorf("user id and session id are required")
+ return
+ }
+ if err := r.notifyPlugins(userID, sessionID, content); err != nil {
+ errs <- err
+ return
+ }
+ impl, ok := r.agent.(*Agent)
+ if !ok {
+ errs <- fmt.Errorf("runner agent %q is not runnable", r.agent.Name())
+ return
+ }
+ if err := impl.stream(context.Background(), content, outputs); err != nil {
+ errs <- err
+ }
+ }()
+ return outputs, errs
+}
+
+func (r *Runner) notifyPlugins(userID, sessionID string, content model.ChatContent) error {
+ for _, item := range r.plugins {
+ if err := item.OnUserMessage(context.Background(), r.appName, userID, sessionID, r.agent, content); err != nil {
+ return fmt.Errorf("plugin %q on user message: %w", item.Name(), err)
+ }
+ if err := item.BeforeAgent(context.Background(), r.appName, userID, sessionID, r.agent); err != nil {
+ return fmt.Errorf("plugin %q before agent: %w", item.Name(), err)
+ }
+ }
+ return nil
+}
+
+func firstText(content model.ChatContent) string {
+ if len(content.Texts) == 0 {
+ return ""
+ }
+ return content.Texts[0].Message
+}
+
+func defaultPlugins() map[string]func() (ports.RunnerPlugin, error) {
+ return map[string]func() (ports.RunnerPlugin, error){
+ "myTestPlugin": newMyTestPlugin,
+ "myLogPlugin": func() (ports.RunnerPlugin, error) {
+ p, err := loggingplugin.New("myLogPlugin")
+ if err != nil {
+ return nil, err
+ }
+ return adkRunnerPlugin{name: "myLogPlugin", plugin: p}, nil
+ },
+ }
+}
+
+func (f *Factory) resolvePlugins(names []string) ([]ports.RunnerPlugin, error) {
+ if len(names) == 0 {
+ return nil, nil
+ }
+ plugins := make([]ports.RunnerPlugin, 0, len(names))
+ for _, name := range names {
+ pluginName := strings.TrimSpace(name)
+ if pluginName == "" {
+ continue
+ }
+ builder, ok := f.plugins[pluginName]
+ if !ok {
+ return nil, fmt.Errorf("runner plugin %q is not registered", pluginName)
+ }
+ plugin, err := builder()
+ if err != nil {
+ return nil, fmt.Errorf("create runner plugin %q: %w", pluginName, err)
+ }
+ plugins = append(plugins, plugin)
+ }
+ return plugins, nil
+}
+
+func newMyTestPlugin() (ports.RunnerPlugin, error) {
+ return myTestPlugin{}, nil
+}
+
+type myTestPlugin struct{}
+
+func (myTestPlugin) Name() string {
+ return "myTestPlugin"
+}
+
+func (myTestPlugin) OnUserMessage(_ context.Context, _, _, _ string, _ ports.Agent, content model.ChatContent) error {
+ fmt.Printf("[myTestPlugin] 用户输入信息:%s\n", firstText(content))
+ return nil
+}
+
+func (myTestPlugin) BeforeAgent(_ context.Context, _, _, _ string, agent ports.Agent) error {
+ fmt.Printf("[myTestPlugin] 智能体名称:%s\n", agent.Name())
+ return nil
+}
+
+type adkRunnerPlugin struct {
+ name string
+ plugin *plugin.Plugin
+}
+
+func (p adkRunnerPlugin) Name() string {
+ return p.name
+}
+
+func (p adkRunnerPlugin) OnUserMessage(_ context.Context, _, _, _ string, _ ports.Agent, content model.ChatContent) error {
+ if p.plugin.OnUserMessageCallback() != nil {
+ fmt.Printf("[%s] USER MESSAGE RECEIVED %s\n", p.name, firstText(content))
+ }
+ return nil
+}
+
+func (p adkRunnerPlugin) BeforeAgent(_ context.Context, _, _, _ string, agent ports.Agent) error {
+ if p.plugin.BeforeAgentCallback() != nil {
+ fmt.Printf("[%s] AGENT STARTING %s\n", p.name, agent.Name())
+ }
+ return nil
+}
diff --git a/internal/infrastructure/ai/eino_adapter.go b/internal/infrastructure/ai/eino_adapter.go
new file mode 100644
index 0000000..f3deb4d
--- /dev/null
+++ b/internal/infrastructure/ai/eino_adapter.go
@@ -0,0 +1,117 @@
+package ai
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+type EinoProvider struct {
+ requestTimeout time.Duration
+}
+
+type EinoAPIConfig struct {
+ BaseURL string
+ APIKey string
+ CompletionsPath string
+ EmbeddingsPath string
+}
+
+type EinoChatModel struct {
+ client *OpenAIClient
+ tools []ports.Tool
+}
+
+type EinoTool struct {
+ ToolName string
+}
+
+func NewEinoProvider() *EinoProvider {
+ return &EinoProvider{requestTimeout: 5 * time.Minute}
+}
+
+func (p *EinoProvider) WithRequestTimeout(timeout time.Duration) *EinoProvider {
+ if timeout > 0 {
+ p.requestTimeout = timeout
+ }
+ return p
+}
+
+func (p *EinoProvider) NewAPI(_ context.Context, config model.AiAPIConfig) (ports.ModelAPI, error) {
+ if strings.TrimSpace(config.BaseURL) == "" {
+ return nil, fmt.Errorf("base url is required")
+ }
+ if strings.TrimSpace(config.APIKey) == "" {
+ return nil, fmt.Errorf("api key is required")
+ }
+ return EinoAPIConfig{
+ BaseURL: config.BaseURL,
+ APIKey: config.APIKey,
+ CompletionsPath: config.CompletionsPath,
+ EmbeddingsPath: config.EmbeddingsPath,
+ }, nil
+}
+
+func (p *EinoProvider) NewChatModel(_ context.Context, api ports.ModelAPI, config model.ChatModelConfig, tools []ports.Tool) (ports.ChatModel, error) {
+ if api == nil {
+ return nil, fmt.Errorf("model api is required")
+ }
+ if strings.TrimSpace(config.Model) == "" {
+ return nil, fmt.Errorf("model is required")
+ }
+ apiCfg, ok := api.(EinoAPIConfig)
+ if !ok {
+ return nil, fmt.Errorf("unsupported model api type %T", api)
+ }
+ completionsURL := joinURL(apiCfg.BaseURL, apiCfg.CompletionsPath)
+ timeout := p.requestTimeout
+ if timeout <= 0 {
+ timeout = 5 * time.Minute
+ }
+ client := NewOpenAIClient(completionsURL, apiCfg.APIKey, config.Model, timeout)
+ return &EinoChatModel{client: client, tools: tools}, nil
+}
+
+func (m *EinoChatModel) Tools() []ports.Tool {
+ return m.tools
+}
+
+func (m *EinoChatModel) Generate(ctx context.Context, messages []ports.ChatMessage) (ports.ChatReply, error) {
+ return m.client.Generate(ctx, messages, m.toolDefs())
+}
+
+func (m *EinoChatModel) Stream(ctx context.Context, messages []ports.ChatMessage) (<-chan ports.ChatStreamEvent, <-chan error) {
+ return m.client.Stream(ctx, messages, m.toolDefs())
+}
+
+func (m *EinoChatModel) toolDefs() []OpenAIToolDef {
+ if len(m.tools) == 0 {
+ return nil
+ }
+ defs := make([]OpenAIToolDef, 0, len(m.tools))
+ for _, tool := range m.tools {
+ desc := ""
+ if d, ok := tool.(ports.ToolDescriptor); ok {
+ desc = d.Description()
+ }
+ defs = append(defs, OpenAIToolDef{Name: tool.Name(), Description: desc})
+ }
+ return defs
+}
+
+func (t EinoTool) Name() string {
+ return t.ToolName
+}
+
+func joinURL(base, path string) string {
+ base = strings.TrimRight(base, "/")
+ path = strings.TrimLeft(path, "/")
+ if path == "" {
+ return base
+ }
+ return base + "/" + path
+}
diff --git a/internal/infrastructure/ai/mcp_sse_client.go b/internal/infrastructure/ai/mcp_sse_client.go
new file mode 100644
index 0000000..cdb5274
--- /dev/null
+++ b/internal/infrastructure/ai/mcp_sse_client.go
@@ -0,0 +1,509 @@
+package ai
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+const (
+ mcpProtocolVersion = "2024-11-05"
+ mcpClientName = "ai-agent-scaffold-go"
+ mcpClientVersion = "0.1.0"
+)
+
+type MCPSSEClient struct {
+ baseURI string
+ endpoint string
+ httpClient *http.Client
+ timeout time.Duration
+
+ mu sync.Mutex
+ started bool
+ postURL string
+ cancelFn context.CancelFunc
+ pending map[uint64]chan json.RawMessage
+ nextID atomic.Uint64
+ streamErr chan error
+ endpointSig chan struct{}
+}
+
+func NewMCPSSEClient(baseURI, endpoint string, requestTimeoutMillis int) *MCPSSEClient {
+ timeout := time.Duration(requestTimeoutMillis) * time.Millisecond
+ if timeout <= 0 {
+ timeout = 120 * time.Second
+ }
+ return &MCPSSEClient{
+ baseURI: strings.TrimRight(baseURI, "/"),
+ endpoint: endpoint,
+ httpClient: &http.Client{Timeout: 0},
+ timeout: timeout,
+ }
+}
+
+func (c *MCPSSEClient) ensureStarted(ctx context.Context) error {
+ c.mu.Lock()
+ if c.started {
+ c.mu.Unlock()
+ return nil
+ }
+ c.pending = make(map[uint64]chan json.RawMessage)
+ c.endpointSig = make(chan struct{})
+ c.streamErr = make(chan error, 1)
+ streamCtx, cancel := context.WithCancel(context.Background())
+ c.cancelFn = cancel
+ c.started = true
+ c.mu.Unlock()
+
+ if err := c.openSSE(streamCtx); err != nil {
+ c.shutdown()
+ return err
+ }
+
+ waitCtx, waitCancel := context.WithTimeout(ctx, c.timeout)
+ defer waitCancel()
+ select {
+ case <-c.endpointSig:
+ case err := <-c.streamErr:
+ c.shutdown()
+ return err
+ case <-waitCtx.Done():
+ c.shutdown()
+ return fmt.Errorf("mcp sse endpoint event timeout: %w", waitCtx.Err())
+ }
+
+ if err := c.initialize(ctx); err != nil {
+ c.shutdown()
+ return err
+ }
+ return nil
+}
+
+func (c *MCPSSEClient) openSSE(ctx context.Context) error {
+ target := c.baseURI + c.endpoint
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
+ if err != nil {
+ return fmt.Errorf("mcp sse build request: %w", err)
+ }
+ req.Header.Set("Accept", "text/event-stream")
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("mcp sse open: %w", err)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ raw, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ return fmt.Errorf("mcp sse status %d: %s", resp.StatusCode, truncate(string(raw), 200))
+ }
+ go c.readLoop(resp)
+ return nil
+}
+
+func (c *MCPSSEClient) readLoop(resp *http.Response) {
+ defer resp.Body.Close()
+ reader := bufio.NewReader(resp.Body)
+ var event string
+ var dataBuf strings.Builder
+ dispatch := func() {
+ defer func() {
+ event = ""
+ dataBuf.Reset()
+ }()
+ data := dataBuf.String()
+ if data == "" {
+ return
+ }
+ switch event {
+ case "endpoint", "":
+ c.handleEndpoint(data, event)
+ case "message":
+ c.handleMessage(data)
+ }
+ }
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err != io.EOF {
+ c.signalStreamErr(fmt.Errorf("mcp sse read: %w", err))
+ } else {
+ c.signalStreamErr(fmt.Errorf("mcp sse stream closed"))
+ }
+ return
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" {
+ dispatch()
+ continue
+ }
+ switch {
+ case strings.HasPrefix(line, "event:"):
+ event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
+ case strings.HasPrefix(line, "data:"):
+ if dataBuf.Len() > 0 {
+ dataBuf.WriteByte('\n')
+ }
+ dataBuf.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ }
+ }
+}
+
+func (c *MCPSSEClient) handleEndpoint(data, eventName string) {
+ if c.postURLAlreadySet() {
+ if eventName == "" {
+ c.handleMessage(data)
+ }
+ return
+ }
+ target := c.resolvePostURL(data)
+ c.mu.Lock()
+ if c.postURL == "" {
+ c.postURL = target
+ close(c.endpointSig)
+ }
+ c.mu.Unlock()
+}
+
+func (c *MCPSSEClient) postURLAlreadySet() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.postURL != ""
+}
+
+func (c *MCPSSEClient) resolvePostURL(raw string) string {
+ parsed, err := url.Parse(raw)
+ if err != nil || !parsed.IsAbs() {
+ base, baseErr := url.Parse(c.baseURI)
+ if baseErr == nil {
+ ref, refErr := url.Parse(raw)
+ if refErr == nil {
+ return base.ResolveReference(ref).String()
+ }
+ }
+ }
+ return raw
+}
+
+func (c *MCPSSEClient) handleMessage(data string) {
+ var resp struct {
+ ID json.Number `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal([]byte(data), &resp); err != nil {
+ return
+ }
+ if resp.ID == "" {
+ return
+ }
+ id, err := resp.ID.Int64()
+ if err != nil || id <= 0 {
+ return
+ }
+ c.mu.Lock()
+ ch, ok := c.pending[uint64(id)]
+ if ok {
+ delete(c.pending, uint64(id))
+ }
+ c.mu.Unlock()
+ if !ok {
+ return
+ }
+ if resp.Error != nil {
+ ch <- mustJSON(map[string]any{"__error__": resp.Error.Message, "code": resp.Error.Code})
+ close(ch)
+ return
+ }
+ ch <- resp.Result
+ close(ch)
+}
+
+func mustJSON(v any) json.RawMessage {
+ raw, _ := json.Marshal(v)
+ return raw
+}
+
+func (c *MCPSSEClient) signalStreamErr(err error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ select {
+ case c.streamErr <- err:
+ default:
+ }
+ for id, ch := range c.pending {
+ ch <- mustJSON(map[string]any{"__error__": err.Error()})
+ close(ch)
+ delete(c.pending, id)
+ }
+}
+
+func (c *MCPSSEClient) shutdown() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.cancelFn != nil {
+ c.cancelFn()
+ }
+ c.started = false
+ c.postURL = ""
+ c.pending = nil
+}
+
+func (c *MCPSSEClient) initialize(ctx context.Context) error {
+ _, err := c.callRPC(ctx, "initialize", map[string]any{
+ "protocolVersion": mcpProtocolVersion,
+ "capabilities": map[string]any{},
+ "clientInfo": map[string]any{
+ "name": mcpClientName,
+ "version": mcpClientVersion,
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("mcp initialize: %w", err)
+ }
+ if err := c.notify(ctx, "notifications/initialized", map[string]any{}); err != nil {
+ return fmt.Errorf("mcp notifications/initialized: %w", err)
+ }
+ return nil
+}
+
+func (c *MCPSSEClient) CallTool(ctx context.Context, name, arguments string) (string, error) {
+ if err := c.ensureStarted(ctx); err != nil {
+ return "", err
+ }
+ args := map[string]any{}
+ trimmed := strings.TrimSpace(arguments)
+ if trimmed != "" {
+ if err := json.Unmarshal([]byte(trimmed), &args); err != nil {
+ return "", fmt.Errorf("mcp tool %q arguments not valid json: %w", name, err)
+ }
+ }
+ result, err := c.callRPC(ctx, "tools/call", map[string]any{
+ "name": name,
+ "arguments": args,
+ })
+ if err != nil {
+ return "", fmt.Errorf("mcp tools/call %q: %w", name, err)
+ }
+ return extractToolText(result), nil
+}
+
+func (c *MCPSSEClient) ListTools(ctx context.Context) ([]string, error) {
+ if err := c.ensureStarted(ctx); err != nil {
+ return nil, err
+ }
+ result, err := c.callRPC(ctx, "tools/list", map[string]any{})
+ if err != nil {
+ return nil, err
+ }
+ var parsed struct {
+ Tools []struct {
+ Name string `json:"name"`
+ } `json:"tools"`
+ }
+ if err := json.Unmarshal(result, &parsed); err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(parsed.Tools))
+ for _, t := range parsed.Tools {
+ names = append(names, t.Name)
+ }
+ return names, nil
+}
+
+type toolCallResult struct {
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ IsError bool `json:"isError"`
+}
+
+func extractToolText(raw json.RawMessage) string {
+ var parsed toolCallResult
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ return string(raw)
+ }
+ parts := make([]string, 0, len(parsed.Content))
+ for _, item := range parsed.Content {
+ if item.Type == "text" && item.Text != "" {
+ parts = append(parts, item.Text)
+ }
+ }
+ if len(parts) == 0 {
+ return string(raw)
+ }
+ return strings.Join(parts, "\n")
+}
+
+func (c *MCPSSEClient) callRPC(ctx context.Context, method string, params any) (json.RawMessage, error) {
+ id := c.nextID.Add(1)
+ ch := make(chan json.RawMessage, 1)
+ c.mu.Lock()
+ postURL := c.postURL
+ c.pending[id] = ch
+ c.mu.Unlock()
+ if postURL == "" {
+ return nil, fmt.Errorf("mcp post url is not set")
+ }
+ body, err := json.Marshal(map[string]any{
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": method,
+ "params": params,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("mcp marshal request: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("mcp build post: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("mcp post: %w", err)
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("mcp post status %d", resp.StatusCode)
+ }
+
+ waitCtx, cancel := context.WithTimeout(ctx, c.timeout)
+ defer cancel()
+ select {
+ case msg, ok := <-ch:
+ if !ok {
+ return nil, fmt.Errorf("mcp call %s closed unexpectedly", method)
+ }
+ var probe struct {
+ Err string `json:"__error__"`
+ }
+ if err := json.Unmarshal(msg, &probe); err == nil && probe.Err != "" {
+ return nil, fmt.Errorf("%s", probe.Err)
+ }
+ return msg, nil
+ case <-waitCtx.Done():
+ return nil, fmt.Errorf("mcp call %s timeout: %w", method, waitCtx.Err())
+ }
+}
+
+func (c *MCPSSEClient) notify(ctx context.Context, method string, params any) error {
+ c.mu.Lock()
+ postURL := c.postURL
+ c.mu.Unlock()
+ if postURL == "" {
+ return fmt.Errorf("mcp post url is not set")
+ }
+ body, err := json.Marshal(map[string]any{
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params,
+ })
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ return nil
+}
+
+type MCPToolRouter struct {
+ clients map[string]*MCPSSEClient
+ tools []ports.Tool
+ registered map[string]struct{}
+ listTimeout time.Duration
+ listFailures map[string]error
+}
+
+func NewMCPToolRouter() *MCPToolRouter {
+ return &MCPToolRouter{
+ clients: make(map[string]*MCPSSEClient),
+ registered: make(map[string]struct{}),
+ listTimeout: 10 * time.Second,
+ listFailures: make(map[string]error),
+ }
+}
+
+func (r *MCPToolRouter) Register(tool ports.Tool) {
+ r.RegisterAndExpand(tool)
+}
+
+func (r *MCPToolRouter) RegisterAndExpand(tool ports.Tool) []ports.Tool {
+ mcp, ok := tool.(MCPTool)
+ if !ok {
+ r.tools = append(r.tools, tool)
+ return []ports.Tool{tool}
+ }
+ if mcp.TransportType != "sse" {
+ r.tools = append(r.tools, mcp)
+ return []ports.Tool{mcp}
+ }
+ if _, exists := r.clients[mcp.ToolName]; exists {
+ return nil
+ }
+ client := NewMCPSSEClient(mcp.BaseURI, mcp.SSEEndpoint, mcp.RequestTimeout)
+ r.clients[mcp.ToolName] = client
+
+ ctx, cancel := context.WithTimeout(context.Background(), r.listTimeout)
+ defer cancel()
+ names, err := client.ListTools(ctx)
+ if err != nil {
+ r.listFailures[mcp.ToolName] = err
+ r.tools = append(r.tools, mcp)
+ return []ports.Tool{mcp}
+ }
+ expanded := make([]ports.Tool, 0, len(names))
+ for _, n := range names {
+ r.clients[n] = client
+ r.registered[n] = struct{}{}
+ t := EinoTool{ToolName: n}
+ r.tools = append(r.tools, t)
+ expanded = append(expanded, t)
+ }
+ return expanded
+}
+
+func (r *MCPToolRouter) Tools() []ports.Tool {
+ return r.tools
+}
+
+func (r *MCPToolRouter) CallTool(ctx context.Context, name, arguments string) (string, error) {
+ if client, ok := r.clients[name]; ok {
+ return client.CallTool(ctx, name, arguments)
+ }
+ for _, tool := range r.tools {
+ if tool.Name() != name {
+ continue
+ }
+ switch tool.(type) {
+ case MCPTool:
+ return "", fmt.Errorf("mcp tool %q transport not supported in runtime", name)
+ default:
+ return "", fmt.Errorf("tool %q is not callable in current runtime", name)
+ }
+ }
+ return "", fmt.Errorf("tool %q is not registered", name)
+}
diff --git a/internal/infrastructure/ai/openai_client.go b/internal/infrastructure/ai/openai_client.go
new file mode 100644
index 0000000..57bc7f9
--- /dev/null
+++ b/internal/infrastructure/ai/openai_client.go
@@ -0,0 +1,319 @@
+package ai
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+)
+
+type OpenAIClient struct {
+ httpClient *http.Client
+ completionsURL string
+ apiKey string
+ model string
+}
+
+type OpenAIToolDef struct {
+ Name string
+ Description string
+}
+
+func NewOpenAIClient(completionsURL, apiKey, model string, requestTimeout time.Duration) *OpenAIClient {
+ if requestTimeout <= 0 {
+ requestTimeout = 5 * time.Minute
+ }
+ return &OpenAIClient{
+ httpClient: &http.Client{Timeout: requestTimeout},
+ completionsURL: completionsURL,
+ apiKey: apiKey,
+ model: model,
+ }
+}
+
+func (c *OpenAIClient) Generate(ctx context.Context, messages []ports.ChatMessage, tools []OpenAIToolDef) (ports.ChatReply, error) {
+ body, err := buildRequestBody(c.model, messages, tools, false)
+ if err != nil {
+ return ports.ChatReply{}, err
+ }
+ resp, err := c.do(ctx, body)
+ if err != nil {
+ return ports.ChatReply{}, err
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return ports.ChatReply{}, fmt.Errorf("openai read body: %w", err)
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return ports.ChatReply{}, fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
+ }
+
+ var parsed openaiCompletion
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ return ports.ChatReply{}, fmt.Errorf("openai decode: %w", err)
+ }
+ if len(parsed.Choices) == 0 {
+ return ports.ChatReply{}, fmt.Errorf("openai response has no choices")
+ }
+ choice := parsed.Choices[0].Message
+ reply := ports.ChatReply{Content: choice.Content}
+ for _, tc := range choice.ToolCalls {
+ reply.ToolCalls = append(reply.ToolCalls, ports.ChatToolCall{
+ ID: tc.ID,
+ Name: tc.Function.Name,
+ Arguments: tc.Function.Arguments,
+ })
+ }
+ return reply, nil
+}
+
+func (c *OpenAIClient) Stream(ctx context.Context, messages []ports.ChatMessage, tools []OpenAIToolDef) (<-chan ports.ChatStreamEvent, <-chan error) {
+ events := make(chan ports.ChatStreamEvent, 8)
+ errs := make(chan error, 1)
+ go func() {
+ defer close(events)
+ defer close(errs)
+
+ body, err := buildRequestBody(c.model, messages, tools, true)
+ if err != nil {
+ errs <- err
+ return
+ }
+ resp, err := c.do(ctx, body)
+ if err != nil {
+ errs <- err
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ raw, _ := io.ReadAll(resp.Body)
+ errs <- fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
+ return
+ }
+
+ toolCallBuf := map[int]*ports.ChatToolCall{}
+ reader := bufio.NewReader(resp.Body)
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF {
+ emitToolCalls(events, toolCallBuf)
+ events <- ports.ChatStreamEvent{Done: true}
+ return
+ }
+ errs <- fmt.Errorf("openai stream read: %w", err)
+ return
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" || !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ if payload == "[DONE]" {
+ emitToolCalls(events, toolCallBuf)
+ events <- ports.ChatStreamEvent{Done: true}
+ return
+ }
+ var chunk openaiStreamChunk
+ if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
+ errs <- fmt.Errorf("openai stream decode: %w", err)
+ return
+ }
+ if len(chunk.Choices) == 0 {
+ continue
+ }
+ delta := chunk.Choices[0].Delta
+ if delta.Content != "" {
+ select {
+ case events <- ports.ChatStreamEvent{Delta: delta.Content}:
+ case <-ctx.Done():
+ errs <- ctx.Err()
+ return
+ }
+ }
+ for _, tc := range delta.ToolCalls {
+ current, ok := toolCallBuf[tc.Index]
+ if !ok {
+ current = &ports.ChatToolCall{}
+ toolCallBuf[tc.Index] = current
+ }
+ if tc.ID != "" {
+ current.ID = tc.ID
+ }
+ if tc.Function.Name != "" {
+ current.Name = tc.Function.Name
+ }
+ if tc.Function.Arguments != "" {
+ current.Arguments += tc.Function.Arguments
+ }
+ }
+ }
+ }()
+ return events, errs
+}
+
+func emitToolCalls(events chan<- ports.ChatStreamEvent, buf map[int]*ports.ChatToolCall) {
+ if len(buf) == 0 {
+ return
+ }
+ calls := make([]ports.ChatToolCall, 0, len(buf))
+ for i := 0; i < len(buf); i++ {
+ if call, ok := buf[i]; ok && call != nil {
+ calls = append(calls, *call)
+ }
+ }
+ if len(calls) > 0 {
+ events <- ports.ChatStreamEvent{ToolCalls: calls}
+ }
+}
+
+func (c *OpenAIClient) do(ctx context.Context, body []byte) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.completionsURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("openai build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ if c.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("openai http: %w", err)
+ }
+ return resp, nil
+}
+
+func buildRequestBody(modelName string, messages []ports.ChatMessage, tools []OpenAIToolDef, stream bool) ([]byte, error) {
+ payload := map[string]any{
+ "model": modelName,
+ "messages": encodeMessages(messages),
+ "stream": stream,
+ }
+ if len(tools) > 0 {
+ payload["tools"] = encodeTools(tools)
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("openai marshal request: %w", err)
+ }
+ return raw, nil
+}
+
+func encodeMessages(messages []ports.ChatMessage) []map[string]any {
+ encoded := make([]map[string]any, 0, len(messages))
+ for _, m := range messages {
+ entry := map[string]any{"role": string(m.Role)}
+ if m.Content != "" {
+ entry["content"] = m.Content
+ } else if m.Role != ports.ChatRoleAssistant || len(m.ToolCalls) == 0 {
+ entry["content"] = ""
+ }
+ if m.Name != "" {
+ entry["name"] = m.Name
+ }
+ if m.ToolCallID != "" {
+ entry["tool_call_id"] = m.ToolCallID
+ }
+ if len(m.ToolCalls) > 0 {
+ calls := make([]map[string]any, 0, len(m.ToolCalls))
+ for _, c := range m.ToolCalls {
+ calls = append(calls, map[string]any{
+ "id": c.ID,
+ "type": "function",
+ "function": map[string]any{
+ "name": c.Name,
+ "arguments": c.Arguments,
+ },
+ })
+ }
+ entry["tool_calls"] = calls
+ }
+ encoded = append(encoded, entry)
+ }
+ return encoded
+}
+
+func encodeTools(tools []OpenAIToolDef) []map[string]any {
+ out := make([]map[string]any, 0, len(tools))
+ for _, t := range tools {
+ out = append(out, map[string]any{
+ "type": "function",
+ "function": map[string]any{
+ "name": t.Name,
+ "description": fallbackDescription(t),
+ "parameters": map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "query": map[string]any{
+ "type": "string",
+ "description": "自由文本输入或检索查询,工具会按其语义解释",
+ },
+ },
+ },
+ },
+ })
+ }
+ return out
+}
+
+func fallbackDescription(t OpenAIToolDef) string {
+ if strings.TrimSpace(t.Description) != "" {
+ return t.Description
+ }
+ return "外部工具 " + t.Name + ",参数 query 为自由文本"
+}
+
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ return s[:max] + "..."
+}
+
+type openaiCompletion struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ ToolCalls []openaiToolCallV1 `json:"tool_calls"`
+ } `json:"message"`
+ } `json:"choices"`
+}
+
+type openaiStreamChunk struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ ToolCalls []openaiStreamToolCall `json:"tool_calls"`
+ } `json:"delta"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+}
+
+type openaiToolCallV1 struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+}
+
+type openaiStreamToolCall struct {
+ Index int `json:"index"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+}
diff --git a/internal/infrastructure/ai/tools.go b/internal/infrastructure/ai/tools.go
new file mode 100644
index 0000000..2abd599
--- /dev/null
+++ b/internal/infrastructure/ai/tools.go
@@ -0,0 +1,508 @@
+package ai
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "ai-agent-scaffold-go/internal/domain/agent/model"
+ "ai-agent-scaffold-go/internal/domain/agent/ports"
+ "gopkg.in/yaml.v3"
+)
+
+type ToolFactory struct {
+ router *MCPToolRouter
+}
+
+type SkillFactory struct{}
+
+type MCPTool struct {
+ ToolName string
+ TransportType string
+ BaseURI string
+ SSEEndpoint string
+ Command string
+ Args []string
+ Env map[string]string
+ RequestTimeout int
+}
+
+type SkillTool struct {
+ ToolName string
+ SkillName string
+ Description string
+ Path string
+ ManifestPath string
+}
+
+func NewToolFactory(router *MCPToolRouter) *ToolFactory {
+ return &ToolFactory{router: router}
+}
+
+func NewSkillFactory() *SkillFactory {
+ return &SkillFactory{}
+}
+
+func (f *ToolFactory) BuildTools(_ context.Context, config model.ToolMCPConfig) ([]ports.Tool, error) {
+ kind, err := validateMCPConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ var (
+ tools []ports.Tool
+ )
+ switch {
+ case kind == "local":
+ tools, err = buildLocalMCPTools(config.Local)
+ case kind == "sse":
+ tools, err = buildSSEMCPTools(config.SSE)
+ case kind == "stdio":
+ tools, err = buildStdioMCPTools(config.Stdio)
+ case config.SSE != nil:
+ tools, err = buildSSEMCPTools(config.SSE)
+ case config.Stdio != nil:
+ tools, err = buildStdioMCPTools(config.Stdio)
+ default:
+ return nil, fmt.Errorf("mcp tool config is empty")
+ }
+ if err != nil {
+ return nil, err
+ }
+ if f.router != nil {
+ expanded := make([]ports.Tool, 0, len(tools))
+ for _, t := range tools {
+ ts := f.router.RegisterAndExpand(t)
+ if len(ts) == 0 {
+ continue
+ }
+ expanded = append(expanded, ts...)
+ }
+ if len(expanded) > 0 {
+ return expanded, nil
+ }
+ }
+ return tools, nil
+}
+
+func (t MCPTool) Name() string {
+ return t.ToolName
+}
+
+func (f *SkillFactory) BuildTools(_ context.Context, config model.ToolSkillsConfig) ([]ports.Tool, error) {
+ skillType := strings.TrimSpace(config.Type)
+ if skillType == "" {
+ skillType = "directory"
+ }
+ if skillType != "directory" && skillType != "resource" {
+ return nil, fmt.Errorf("unsupported skill type %q", config.Type)
+ }
+
+ rawPath := strings.TrimSpace(config.Path)
+ if rawPath == "" {
+ return nil, fmt.Errorf("skill path is required")
+ }
+
+ root, err := resolveSkillRoot(skillType, rawPath)
+ if err != nil {
+ return nil, err
+ }
+ manifests, err := findSkillManifests(root)
+ if err != nil {
+ return nil, err
+ }
+ if len(manifests) == 0 {
+ return nil, fmt.Errorf("skill path %q has no SKILL.md files", root)
+ }
+
+ tools := make([]ports.Tool, 0, len(manifests))
+ for _, manifest := range manifests {
+ tool, err := loadSkillTool(root, manifest)
+ if err != nil {
+ return nil, err
+ }
+ tools = append(tools, tool)
+ }
+ return tools, nil
+}
+
+func (t SkillTool) Name() string {
+ return t.ToolName
+}
+
+func resolveSkillRoot(skillType, rawPath string) (string, error) {
+ if filepath.IsAbs(rawPath) {
+ return existingPath(rawPath, rawPath)
+ }
+
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("resolve skill path %q: %w", rawPath, err)
+ }
+
+ candidates := skillPathCandidates(cwd, skillType, rawPath)
+ for _, candidate := range candidates {
+ if path, err := existingPath(candidate, rawPath); err == nil {
+ return path, nil
+ }
+ }
+ return "", fmt.Errorf("skill path %q cannot be resolved", rawPath)
+}
+
+func skillPathCandidates(cwd, skillType, rawPath string) []string {
+ var candidates []string
+ add := func(path string) {
+ clean := filepath.Clean(path)
+ for _, candidate := range candidates {
+ if candidate == clean {
+ return
+ }
+ }
+ candidates = append(candidates, clean)
+ }
+
+ for dir := cwd; ; dir = filepath.Dir(dir) {
+ add(filepath.Join(dir, rawPath))
+ add(filepath.Join(dir, "configs", rawPath))
+ add(filepath.Join(dir, "ai-agent-scaffold-go", rawPath))
+ add(filepath.Join(dir, "ai-agent-scaffold-go", "configs", rawPath))
+ if skillType == "resource" && strings.HasPrefix(rawPath, "agent"+string(filepath.Separator)) {
+ add(filepath.Join(dir, "configs", rawPath))
+ add(filepath.Join(dir, "ai-agent-scaffold-go", "configs", rawPath))
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ }
+ return candidates
+}
+
+func existingPath(candidate, original string) (string, error) {
+ info, err := os.Stat(candidate)
+ if err != nil {
+ return "", fmt.Errorf("skill path %q cannot be resolved", original)
+ }
+ if !info.IsDir() && filepath.Base(candidate) != "SKILL.md" {
+ return "", fmt.Errorf("skill path %q is not a directory or SKILL.md file", candidate)
+ }
+ return candidate, nil
+}
+
+func findSkillManifests(root string) ([]string, error) {
+ info, err := os.Stat(root)
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() {
+ return []string{root}, nil
+ }
+
+ var manifests []string
+ err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ if entry.Name() == "SKILL.md" {
+ manifests = append(manifests, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("scan skill path %q: %w", root, err)
+ }
+ sort.Strings(manifests)
+ return manifests, nil
+}
+
+func loadSkillTool(root, manifest string) (SkillTool, error) {
+ data, err := os.ReadFile(manifest)
+ if err != nil {
+ return SkillTool{}, fmt.Errorf("read skill manifest %q: %w", manifest, err)
+ }
+ meta, err := parseSkillFrontMatter(string(data))
+ if err != nil {
+ return SkillTool{}, fmt.Errorf("parse skill manifest %q: %w", manifest, err)
+ }
+
+ skillDir := filepath.Dir(manifest)
+ name := strings.TrimSpace(meta["name"])
+ if name == "" {
+ name = filepath.Base(skillDir)
+ }
+ description := strings.TrimSpace(meta["description"])
+
+ return SkillTool{
+ ToolName: "skill_" + sanitizeToolName(name),
+ SkillName: name,
+ Description: description,
+ Path: skillDir,
+ ManifestPath: manifest,
+ }, nil
+}
+
+func sanitizeToolName(name string) string {
+ var b strings.Builder
+ for _, r := range name {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
+ b.WriteRune(r)
+ default:
+ b.WriteByte('_')
+ }
+ }
+ out := b.String()
+ if out == "" {
+ out = "tool"
+ }
+ if len(out) > 64 {
+ out = out[:64]
+ }
+ return out
+}
+
+func parseSkillFrontMatter(content string) (map[string]string, error) {
+ result := make(map[string]string)
+ if !strings.HasPrefix(content, "---") {
+ return result, nil
+ }
+
+ rest := strings.TrimPrefix(content, "---")
+ end := strings.Index(rest, "\n---")
+ if end < 0 {
+ return result, fmt.Errorf("front matter terminator is required")
+ }
+
+ frontMatter := rest[:end]
+ if err := yaml.Unmarshal([]byte(frontMatter), &result); err != nil {
+ return nil, err
+ }
+ return result, nil
+}
+
+func validateMCPConfig(config model.ToolMCPConfig) (string, error) {
+ count := 0
+ kind := ""
+ if config.Local != nil {
+ count++
+ kind = "local"
+ }
+ if config.SSE != nil {
+ count++
+ kind = "sse"
+ }
+ if config.Stdio != nil {
+ count++
+ kind = "stdio"
+ }
+
+ switch count {
+ case 0:
+ return "", fmt.Errorf("mcp config must define exactly one of local, sse, or stdio")
+ case 1:
+ return kind, nil
+ default:
+ return "", fmt.Errorf("mcp config cannot define multiple transports in one entry")
+ }
+}
+
+func buildLocalMCPTools(config *model.LocalToolParameters) ([]ports.Tool, error) {
+ name := strings.TrimSpace(config.Name)
+ if name == "" {
+ return nil, fmt.Errorf("local mcp tool name is required")
+ }
+
+ switch name {
+ case "echoLocalTool":
+ return []ports.Tool{MCPTool{ToolName: name, TransportType: "local"}}, nil
+ default:
+ return nil, fmt.Errorf("local mcp tool %q is not registered in go", name)
+ }
+}
+
+func buildSSEMCPTools(config *model.SSEServerParameters) ([]ports.Tool, error) {
+ name := strings.TrimSpace(config.Name)
+ if name == "" {
+ return nil, fmt.Errorf("sse mcp tool name is required")
+ }
+ baseURI := strings.TrimSpace(config.BaseURI)
+ if baseURI == "" {
+ return nil, fmt.Errorf("sse mcp tool %q base-uri is required", name)
+ }
+
+ normalizedBaseURI, endpoint, err := normalizeSSETarget(baseURI, strings.TrimSpace(config.SSEEndpoint))
+ if err != nil {
+ return nil, fmt.Errorf("sse mcp tool %q: %w", name, err)
+ }
+ timeout := normalizeTimeout(config.RequestTimeout)
+
+ return []ports.Tool{MCPTool{
+ ToolName: name,
+ TransportType: "sse",
+ BaseURI: normalizedBaseURI,
+ SSEEndpoint: endpoint,
+ RequestTimeout: timeout,
+ }}, nil
+}
+
+func buildStdioMCPTools(config *model.StdioServerParameters) ([]ports.Tool, error) {
+ name := strings.TrimSpace(config.Name)
+ if name == "" {
+ return nil, fmt.Errorf("stdio mcp tool name is required")
+ }
+ command := strings.TrimSpace(config.ServerParameters.Command)
+ if command == "" {
+ return nil, fmt.Errorf("stdio mcp tool %q command is required", name)
+ }
+ timeout := normalizeTimeout(config.RequestTimeout)
+
+ return []ports.Tool{MCPTool{
+ ToolName: name,
+ TransportType: "stdio",
+ Command: command,
+ Args: append([]string(nil), config.ServerParameters.Args...),
+ Env: cloneEnv(config.ServerParameters.Env),
+ RequestTimeout: timeout,
+ }}, nil
+}
+
+func normalizeSSETarget(baseURI, endpoint string) (string, string, error) {
+ parsed, err := url.Parse(baseURI)
+ if err != nil {
+ return "", "", fmt.Errorf("invalid base-uri: %w", err)
+ }
+ if parsed.Scheme == "" || parsed.Host == "" {
+ return "", "", fmt.Errorf("base-uri must include scheme and host")
+ }
+
+ host := strings.TrimRight(parsed.Scheme+"://"+parsed.Host, "/")
+ basePath := parsed.RawPath
+ if basePath == "" {
+ basePath = parsed.EscapedPath()
+ }
+ baseQuery := parsed.RawQuery
+
+ if endpoint == "" {
+ if basePath == "" || basePath == "/" {
+ return host, "/sse", nil
+ }
+ merged := basePath
+ if baseQuery != "" {
+ merged += "?" + baseQuery
+ }
+ return host, normalizeEndpoint(merged), nil
+ }
+
+ endpointPath, endpointQuery := splitPathQuery(endpoint)
+ mergedPath := joinPaths(basePath, endpointPath)
+ mergedQuery := mergeQueries(baseQuery, endpointQuery)
+ if mergedQuery != "" {
+ mergedPath += "?" + mergedQuery
+ }
+ return host, normalizeEndpoint(mergedPath), nil
+}
+
+func splitPathQuery(raw string) (string, string) {
+ if idx := strings.Index(raw, "?"); idx >= 0 {
+ return raw[:idx], raw[idx+1:]
+ }
+ return raw, ""
+}
+
+func joinPaths(base, extra string) string {
+ if extra == "" {
+ if base == "" {
+ return "/"
+ }
+ return base
+ }
+ if strings.HasPrefix(extra, "/") {
+ return extra
+ }
+ if base == "" {
+ return "/" + extra
+ }
+ if strings.HasSuffix(base, "/") {
+ return base + extra
+ }
+ return base + "/" + extra
+}
+
+func mergeQueries(base, extra string) string {
+ switch {
+ case base == "" && extra == "":
+ return ""
+ case base == "":
+ return extra
+ case extra == "":
+ return base
+ default:
+ return base + "&" + extra
+ }
+}
+
+func normalizeEndpoint(endpoint string) string {
+ trimmed := strings.TrimSpace(endpoint)
+ if trimmed == "" {
+ return "/sse"
+ }
+ if strings.HasPrefix(trimmed, "/") {
+ return trimmed
+ }
+ return "/" + trimmed
+}
+
+func normalizeTimeout(timeout int) int {
+ if timeout > 0 {
+ return timeout
+ }
+ return 300000
+}
+
+func cloneEnv(values map[string]string) map[string]string {
+ if len(values) == 0 {
+ return nil
+ }
+ cloned := make(map[string]string, len(values))
+ for key, value := range values {
+ cloned[key] = value
+ }
+ return cloned
+}
+
+func maskSecretPath(raw string) string {
+ if strings.TrimSpace(raw) == "" {
+ return raw
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return raw
+ }
+ query := parsed.Query()
+ changed := false
+ for key := range query {
+ upper := strings.ToUpper(key)
+ if strings.Contains(upper, "KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") {
+ query.Set(key, "${"+strings.ToUpper(strings.ReplaceAll(key, "-", "_"))+"}")
+ changed = true
+ }
+ }
+ if !changed {
+ return raw
+ }
+ parsed.RawQuery = query.Encode()
+ return parsed.String()
+}
+
+func mcpTimeoutString(timeout int) string {
+ return strconv.Itoa(normalizeTimeout(timeout))
+}
diff --git a/internal/infrastructure/cache/redis.go b/internal/infrastructure/cache/redis.go
new file mode 100644
index 0000000..79d2350
--- /dev/null
+++ b/internal/infrastructure/cache/redis.go
@@ -0,0 +1,35 @@
+package cache
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/redis/go-redis/v9"
+)
+
+type RedisConfig struct {
+ Required bool
+ Addr string
+ Password string
+ DB int
+}
+
+func OpenRedis(config RedisConfig) (*redis.Client, error) {
+ if strings.TrimSpace(config.Addr) == "" {
+ if config.Required {
+ return nil, fmt.Errorf("redis addr is required")
+ }
+ return nil, nil
+ }
+ client := redis.NewClient(&redis.Options{
+ Addr: config.Addr,
+ Password: config.Password,
+ DB: config.DB,
+ })
+ if err := client.Ping(context.Background()).Err(); err != nil {
+ _ = client.Close()
+ return nil, fmt.Errorf("ping redis: %w", err)
+ }
+ return client, nil
+}
diff --git a/internal/infrastructure/dependencies/dependencies.go b/internal/infrastructure/dependencies/dependencies.go
new file mode 100644
index 0000000..ed04d1e
--- /dev/null
+++ b/internal/infrastructure/dependencies/dependencies.go
@@ -0,0 +1,20 @@
+// Package dependencies anchors verified runtime dependencies while adapters are added.
+package dependencies
+
+import (
+ _ "github.com/cloudwego/eino/adk"
+ _ "github.com/cloudwego/eino/components/model"
+ _ "github.com/cloudwego/eino/components/tool"
+ _ "github.com/gin-gonic/gin"
+ _ "github.com/redis/go-redis/v9"
+ _ "go.uber.org/zap"
+ _ "google.golang.org/adk/agent"
+ _ "google.golang.org/adk/agent/llmagent"
+ _ "google.golang.org/adk/agent/workflowagents/loopagent"
+ _ "google.golang.org/adk/agent/workflowagents/parallelagent"
+ _ "google.golang.org/adk/agent/workflowagents/sequentialagent"
+ _ "google.golang.org/adk/runner"
+ _ "gopkg.in/yaml.v3"
+ _ "gorm.io/driver/mysql"
+ _ "gorm.io/gorm"
+)
diff --git a/internal/infrastructure/doc.go b/internal/infrastructure/doc.go
new file mode 100644
index 0000000..1726aff
--- /dev/null
+++ b/internal/infrastructure/doc.go
@@ -0,0 +1,2 @@
+// Package infrastructure contains adapters for persistence, cache, AI, and runtime providers.
+package infrastructure
diff --git a/internal/infrastructure/logging/logger.go b/internal/infrastructure/logging/logger.go
new file mode 100644
index 0000000..e516d37
--- /dev/null
+++ b/internal/infrastructure/logging/logger.go
@@ -0,0 +1,10 @@
+package logging
+
+import "go.uber.org/zap"
+
+func New(env string) (*zap.Logger, error) {
+ if env == "prod" || env == "production" {
+ return zap.NewProduction()
+ }
+ return zap.NewDevelopment()
+}
diff --git a/internal/infrastructure/persistence/mysql.go b/internal/infrastructure/persistence/mysql.go
new file mode 100644
index 0000000..56e0234
--- /dev/null
+++ b/internal/infrastructure/persistence/mysql.go
@@ -0,0 +1,35 @@
+package persistence
+
+import (
+ "fmt"
+ "strings"
+
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+)
+
+type MySQLConfig struct {
+ Required bool
+ DSN string
+}
+
+func OpenMySQL(config MySQLConfig) (*gorm.DB, error) {
+ if strings.TrimSpace(config.DSN) == "" {
+ if config.Required {
+ return nil, fmt.Errorf("mysql dsn is required")
+ }
+ return nil, nil
+ }
+ db, err := gorm.Open(mysql.Open(config.DSN), &gorm.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("open mysql: %w", err)
+ }
+ sqlDB, err := db.DB()
+ if err != nil {
+ return nil, fmt.Errorf("mysql db handle: %w", err)
+ }
+ if err := sqlDB.Ping(); err != nil {
+ return nil, fmt.Errorf("ping mysql: %w", err)
+ }
+ return db, nil
+}
diff --git a/internal/trigger/doc.go b/internal/trigger/doc.go
new file mode 100644
index 0000000..9291672
--- /dev/null
+++ b/internal/trigger/doc.go
@@ -0,0 +1,2 @@
+// Package trigger contains inbound adapters such as HTTP handlers.
+package trigger
diff --git a/internal/trigger/http/agent_handler.go b/internal/trigger/http/agent_handler.go
new file mode 100644
index 0000000..53bcc58
--- /dev/null
+++ b/internal/trigger/http/agent_handler.go
@@ -0,0 +1,109 @@
+package http
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "ai-agent-scaffold-go/internal/api/dto"
+ "ai-agent-scaffold-go/internal/api/response"
+ "ai-agent-scaffold-go/internal/domain/agent/service/chat"
+ "ai-agent-scaffold-go/pkg/types"
+ "github.com/gin-gonic/gin"
+)
+
+func RegisterAgentRoutes(router gin.IRouter, service *chat.Service) {
+ group := router.Group("/api/v1")
+ group.GET("/query_ai_agent_config_list", queryAgentConfigList(service))
+ group.POST("/create_session", createSession(service))
+ group.GET("/create_session", createSessionQuery(service))
+ group.POST("/chat", chatMessage(service))
+ group.POST("/chat_stream", chatStream(service))
+}
+
+func queryAgentConfigList(service *chat.Service) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ agents := service.QueryAgentConfigList()
+ responses := make([]dto.AiAgentConfigResponse, 0, len(agents))
+ for _, agent := range agents {
+ responses = append(responses, dto.AiAgentConfigResponse{
+ AgentID: agent.AgentID,
+ AgentName: agent.AgentName,
+ AgentDesc: agent.AgentDesc,
+ })
+ }
+ c.JSON(http.StatusOK, response.Success(responses))
+ }
+}
+
+func createSession(service *chat.Service) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var request dto.CreateSessionRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
+ return
+ }
+ sessionID, err := service.CreateSession(request.AgentID, request.UserID)
+ if err != nil {
+ writeError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, response.Success(dto.CreateSessionResponse{SessionID: sessionID}))
+ }
+}
+
+func createSessionQuery(service *chat.Service) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sessionID, err := service.CreateSession(c.Query("agentId"), c.Query("userId"))
+ if err != nil {
+ writeError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, response.Success(dto.CreateSessionResponse{SessionID: sessionID}))
+ }
+}
+
+func chatMessage(service *chat.Service) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var request dto.ChatRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
+ return
+ }
+ outputs, err := service.HandleMessage(request.AgentID, request.UserID, request.SessionID, request.Message)
+ if err != nil {
+ writeError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, response.Success(dto.ChatResponse{Content: strings.Join(outputs, "\n")}))
+ }
+}
+
+func chatStream(service *chat.Service) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var request dto.ChatRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
+ return
+ }
+ outputs, errs := service.HandleMessageStream(request.AgentID, request.UserID, request.SessionID, request.Message)
+ c.Header("Content-Type", "text/event-stream")
+ for output := range outputs {
+ c.SSEvent("message", output)
+ c.Writer.Flush()
+ }
+ if err, ok := <-errs; ok && err != nil {
+ c.SSEvent("error", err.Error())
+ c.Writer.Flush()
+ }
+ }
+}
+
+func writeError(c *gin.Context, err error) {
+ var appErr *types.AppError
+ if errors.As(err, &appErr) {
+ c.JSON(http.StatusOK, response.Failure(appErr.Code, appErr.Info))
+ return
+ }
+ c.JSON(http.StatusOK, response.Failure(types.CodeUnknownError, err.Error()))
+}
diff --git a/pkg/types/codes.go b/pkg/types/codes.go
new file mode 100644
index 0000000..389990e
--- /dev/null
+++ b/pkg/types/codes.go
@@ -0,0 +1,12 @@
+package types
+
+const (
+ CodeSuccess = "0000"
+ InfoSuccess = "success"
+ CodeUnknownError = "0001"
+ InfoUnknownError = "unknown error"
+ CodeIllegalParameter = "0002"
+ InfoIllegalParameter = "illegal parameter"
+ CodeAgentNotFound = "0003"
+ InfoAgentNotFound = "agent not found"
+)
diff --git a/pkg/types/doc.go b/pkg/types/doc.go
new file mode 100644
index 0000000..3cbd4a2
--- /dev/null
+++ b/pkg/types/doc.go
@@ -0,0 +1,2 @@
+// Package types contains shared public support types for the scaffold.
+package types
diff --git a/pkg/types/errors.go b/pkg/types/errors.go
new file mode 100644
index 0000000..99ea0f0
--- /dev/null
+++ b/pkg/types/errors.go
@@ -0,0 +1,17 @@
+package types
+
+type AppError struct {
+ Code string
+ Info string
+}
+
+func NewAppError(code, info string) *AppError {
+ return &AppError{Code: code, Info: info}
+}
+
+func (e *AppError) Error() string {
+ if e == nil {
+ return ""
+ }
+ return e.Code + ": " + e.Info
+}
|