diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/ExecuteSqlTool.java b/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/ExecuteSqlTool.java index 8eba015..80624da 100644 --- a/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/ExecuteSqlTool.java +++ b/data-agent-backend/src/main/java/io/github/malonetalk/agent/tools/ExecuteSqlTool.java @@ -51,6 +51,7 @@ public ToolResultBlock executeSql( () -> { Datasource datasource = dataSourceService.getDatasourceForSession(ctx.sessionId()); + dataSourceService.bindSessionDatasource(ctx.sessionId(), datasource.getId()); QueryResult result = sqlExecutor.execute(datasource, sql); return ToolResultBlock.text(formatResult(result)); }); diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/controller/DashboardController.java b/data-agent-backend/src/main/java/io/github/malonetalk/controller/DashboardController.java new file mode 100644 index 0000000..ac7ac4d --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/controller/DashboardController.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.controller; + +import io.github.malonetalk.common.Result; +import io.github.malonetalk.dto.DashboardDtos.DashboardCardCreateRequest; +import io.github.malonetalk.dto.DashboardDtos.DashboardCardRefreshResponse; +import io.github.malonetalk.dto.DashboardDtos.DashboardCardResponse; +import io.github.malonetalk.service.DashboardService; +import io.github.malonetalk.utils.RequestAssert; +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + + @GetMapping("/dashboard-cards") + public Result> listCards() { + return Result.success(dashboardService.listCards()); + } + + @PostMapping("/dashboard-cards") + public Result createCard( + @Valid @RequestBody DashboardCardCreateRequest request) { + return Result.success(dashboardService.createCard(request)); + } + + @DeleteMapping("/dashboard-cards/{id}") + public Result deleteCard(@PathVariable Integer id) { + RequestAssert.requireNonNegative(id, "id must be non-negative."); + dashboardService.deleteCard(id); + return Result.success(); + } + + @PostMapping("/dashboard-cards/refresh") + public Result> refreshCards( + @RequestBody List ids) { + return Result.success(dashboardService.refreshCards(ids)); + } +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/dto/DashboardDtos.java b/data-agent-backend/src/main/java/io/github/malonetalk/dto/DashboardDtos.java new file mode 100644 index 0000000..5e99265 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/dto/DashboardDtos.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.dto; + +import io.github.malonetalk.agent.datasource.QueryResult; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class DashboardDtos { + + private DashboardDtos() {} + + public record DashboardCardCreateRequest( + @NotBlank(message = "title cannot be blank.") + @Size(max = 255, message = "title length cannot exceed 255.") + String title, + @NotBlank(message = "sessionId cannot be blank.") String sessionId, + @NotBlank(message = "sqlText cannot be blank.") String sqlText, + @NotBlank(message = "chartType cannot be blank.") String chartType) {} + + public record DashboardCardResponse( + Integer id, String title, Integer datasourceId, String sqlText, String chartType) {} + + public record DashboardCardRefreshResponse(QueryResult result, String errorMessage) {} +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/entity/DashboardCard.java b/data-agent-backend/src/main/java/io/github/malonetalk/entity/DashboardCard.java new file mode 100644 index 0000000..e04c3a9 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/entity/DashboardCard.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.entity; + +import lombok.Data; + +@Data +public class DashboardCard { + + private Integer id; + private String title; + private Integer datasourceId; + private String sqlText; + private String chartType; + private Integer creatorId; +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/mapper/DashboardMapper.java b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/DashboardMapper.java new file mode 100644 index 0000000..996ce56 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/mapper/DashboardMapper.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.mapper; + +import io.github.malonetalk.entity.DashboardCard; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface DashboardMapper { + + int insertCard(DashboardCard card); + + List selectCardsByCreator(@Param("creatorId") Integer creatorId); + + List selectCardsByIdsAndCreator( + @Param("ids") List ids, @Param("creatorId") Integer creatorId); + + int deleteCard(@Param("id") Integer id, @Param("creatorId") Integer creatorId); +} diff --git a/data-agent-backend/src/main/java/io/github/malonetalk/service/DashboardService.java b/data-agent-backend/src/main/java/io/github/malonetalk/service/DashboardService.java new file mode 100644 index 0000000..23433b6 --- /dev/null +++ b/data-agent-backend/src/main/java/io/github/malonetalk/service/DashboardService.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * limitations under the License. + */ +package io.github.malonetalk.service; + +import io.github.malonetalk.agent.SessionService; +import io.github.malonetalk.agent.datasource.QueryResult; +import io.github.malonetalk.agent.datasource.SqlExecutor; +import io.github.malonetalk.common.ErrorCode; +import io.github.malonetalk.common.UserContext; +import io.github.malonetalk.dto.DashboardDtos.DashboardCardCreateRequest; +import io.github.malonetalk.dto.DashboardDtos.DashboardCardRefreshResponse; +import io.github.malonetalk.dto.DashboardDtos.DashboardCardResponse; +import io.github.malonetalk.entity.DashboardCard; +import io.github.malonetalk.entity.Datasource; +import io.github.malonetalk.exception.BusinessException; +import io.github.malonetalk.mapper.DashboardMapper; +import io.github.malonetalk.utils.RequestAssert; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DashboardService { + + private static final int MAX_REFRESH_CARDS = 10; + + private final DashboardMapper dashboardMapper; + private final DatasourceService datasourceService; + private final SessionService sessionService; + private final SqlExecutor sqlExecutor; + + public List listCards() { + return dashboardMapper.selectCardsByCreator(currentUserId()).stream() + .map(this::toCardResponse) + .toList(); + } + + @Transactional + public DashboardCardResponse createCard(DashboardCardCreateRequest request) { + UserContext user = UserContext.require(); + sessionService.requireOwnership(user.scopedUserId(), request.sessionId()); + Datasource datasource = datasourceService.getDatasourceForSession(request.sessionId()); + String sql = sqlExecutor.validateSelectSql(request.sqlText()); + + DashboardCard card = new DashboardCard(); + card.setTitle(RequestAssert.requireNotBlank(request.title(), "title cannot be blank.")); + card.setDatasourceId(datasource.getId()); + card.setSqlText(sql); + card.setChartType(normalizeChartType(request.chartType())); + card.setCreatorId(user.userId()); + if (dashboardMapper.insertCard(card) <= 0) { + throw BusinessException.of( + ErrorCode.OPERATION_FAILED, "Failed to save dashboard card."); + } + return toCardResponse(card); + } + + @Transactional + public void deleteCard(Integer id) { + if (dashboardMapper.deleteCard(id, currentUserId()) <= 0) { + throw BusinessException.of( + ErrorCode.RESOURCE_NOT_FOUND, "Dashboard card does not exist: id=" + id); + } + } + + public Map refreshCards(List ids) { + RequestAssert.requireNotEmpty(ids, "card ids cannot be empty."); + ids.forEach(id -> RequestAssert.requireNonNegative(id, "id must be non-negative.")); + List cardIds = ids.stream().distinct().toList(); + if (cardIds.size() > MAX_REFRESH_CARDS) { + throw BusinessException.of( + ErrorCode.BAD_REQUEST, + "Cannot refresh more than " + MAX_REFRESH_CARDS + " dashboard cards."); + } + List cards = + dashboardMapper.selectCardsByIdsAndCreator(cardIds, currentUserId()); + if (cards.size() != cardIds.size()) { + throw BusinessException.of( + ErrorCode.RESOURCE_NOT_FOUND, "Dashboard card does not exist."); + } + + Map results = new LinkedHashMap<>(); + for (DashboardCard card : cards) { + results.put(card.getId(), refreshCardSafely(card)); + } + return results; + } + + private DashboardCardRefreshResponse refreshCardSafely(DashboardCard card) { + try { + return new DashboardCardRefreshResponse(refreshCard(card), null); + } catch (BusinessException e) { + return new DashboardCardRefreshResponse(null, e.getMessage()); + } + } + + private QueryResult refreshCard(DashboardCard card) { + Datasource datasource = requireDatasource(card.getDatasourceId()); + return sqlExecutor.execute(datasource, card.getSqlText()); + } + + private Datasource requireDatasource(Integer id) { + RequestAssert.requireNonNull(id, "datasourceId cannot be null."); + Datasource datasource = datasourceService.findById(id); + if (datasource == null) { + throw BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "Datasource not found."); + } + return datasource; + } + + private String normalizeChartType(String chartType) { + String normalized = RequestAssert.requireNotBlank(chartType, "chartType cannot be blank."); + normalized = normalized.toLowerCase(); + return switch (normalized) { + case "table", "metric", "bar" -> normalized; + default -> + throw BusinessException.of( + ErrorCode.BAD_REQUEST, "chartType must be table, metric or bar."); + }; + } + + private Integer currentUserId() { + return UserContext.require().userId(); + } + + private DashboardCardResponse toCardResponse(DashboardCard card) { + return new DashboardCardResponse( + card.getId(), + card.getTitle(), + card.getDatasourceId(), + card.getSqlText(), + card.getChartType()); + } +} diff --git a/data-agent-backend/src/main/resources/mapper/DashboardMapper.xml b/data-agent-backend/src/main/resources/mapper/DashboardMapper.xml new file mode 100644 index 0000000..999550f --- /dev/null +++ b/data-agent-backend/src/main/resources/mapper/DashboardMapper.xml @@ -0,0 +1,34 @@ + + + + + + INSERT INTO dashboard_card ( + title, datasource_id, sql_text, chart_type, creator_id + ) VALUES ( + #{title}, #{datasourceId}, #{sqlText}, #{chartType}, #{creatorId} + ) + + + + + + + + DELETE FROM dashboard_card + WHERE id = #{id} + AND creator_id = #{creatorId} + + diff --git a/data-agent-frontend/src/api/dashboard.ts b/data-agent-frontend/src/api/dashboard.ts new file mode 100644 index 0000000..628f338 --- /dev/null +++ b/data-agent-frontend/src/api/dashboard.ts @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 github.com/MaloneTalk + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or any later version. + * + * This program is distributed in the hope that it will be useful + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import request, { type ApiResponse } from './request'; + +export type ChartType = 'table' | 'metric' | 'bar'; + +export interface QueryResult { + columns: string[]; + rows: Record[]; + totalRows: number; + truncated: boolean; +} + +export interface DashboardCardResponse { + id: number; + title: string; + datasourceId: number; + sqlText: string; + chartType: ChartType; +} + +export interface DashboardCardCreateRequest { + title: string; + sessionId: string; + sqlText: string; + chartType: ChartType; +} + +export interface DashboardCardRefreshResponse { + result: QueryResult | null; + errorMessage: string | null; +} + +export function getDashboardCards() { + return request.get>('/dashboard-cards'); +} + +export function createDashboardCard(data: DashboardCardCreateRequest) { + return request.post>('/dashboard-cards', data); +} + +export function deleteDashboardCard(id: number) { + return request.delete>(`/dashboard-cards/${id}`); +} + +export function refreshDashboardCards(ids: number[]) { + return request.post>>( + '/dashboard-cards/refresh', + ids, + ); +} diff --git a/data-agent-frontend/src/components/layout/AppSidebar.vue b/data-agent-frontend/src/components/layout/AppSidebar.vue index 4e0b6cd..60b7b2b 100644 --- a/data-agent-frontend/src/components/layout/AppSidebar.vue +++ b/data-agent-frontend/src/components/layout/AppSidebar.vue @@ -42,6 +42,7 @@ title: '资产管理', children: [ { path: '/asset/report', title: '报告管理' }, + { path: '/asset/dashboard', title: '看板管理' }, { path: '/asset/table-export', title: '表格导出' }, ], }, diff --git a/data-agent-frontend/src/router/index.ts b/data-agent-frontend/src/router/index.ts index 62e4818..5d78bf6 100644 --- a/data-agent-frontend/src/router/index.ts +++ b/data-agent-frontend/src/router/index.ts @@ -87,6 +87,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/report/ReportList.vue'), meta: { title: '报告管理' }, }, + { + path: '/asset/dashboard', + name: 'DashboardManage', + component: () => import('@/views/dashboard/DashboardManage.vue'), + meta: { title: '看板管理' }, + }, { path: '/asset/table-export', name: 'TableExportList', @@ -125,6 +131,10 @@ const routes: RouteRecordRaw[] = [ path: '/report', redirect: '/asset/report', }, + { + path: '/dashboard', + redirect: '/asset/dashboard', + }, { path: '/table-export', redirect: '/asset/table-export', diff --git a/data-agent-frontend/src/views/chat/ChatView.vue b/data-agent-frontend/src/views/chat/ChatView.vue index 9bc60bf..8d6fe94 100644 --- a/data-agent-frontend/src/views/chat/ChatView.vue +++ b/data-agent-frontend/src/views/chat/ChatView.vue @@ -18,6 +18,8 @@ diff --git a/data-agent-frontend/src/views/chat/components/ChatMessage.vue b/data-agent-frontend/src/views/chat/components/ChatMessage.vue index 3c7e281..4c721d5 100644 --- a/data-agent-frontend/src/views/chat/components/ChatMessage.vue +++ b/data-agent-frontend/src/views/chat/components/ChatMessage.vue @@ -30,6 +30,7 @@ const emit = defineEmits<{ (e: 'previewReport', content: string): void; + (e: 'saveSqlCard', sql: string): void; }>(); const SUMMARY_MARKER = '\n\nSummary:\n'; @@ -99,6 +100,7 @@ v-if="message.role === 'agent'" :message="message" @preview-report="c => emit('previewReport', c)" + @save-sql-card="sql => emit('saveSqlCard', sql)" />
diff --git a/data-agent-frontend/src/views/chat/components/TracePanel.vue b/data-agent-frontend/src/views/chat/components/TracePanel.vue index e57b44f..a0075f4 100644 --- a/data-agent-frontend/src/views/chat/components/TracePanel.vue +++ b/data-agent-frontend/src/views/chat/components/TracePanel.vue @@ -27,6 +27,7 @@ const emit = defineEmits<{ (e: 'previewReport', content: string): void; + (e: 'saveSqlCard', sql: string): void; }>(); const isExpanded = ref(false); @@ -125,6 +126,20 @@ if (!text) return ''; return text.replace(/\\n/g, '\n'); } + + function sqlFromStep(step: TraceStep): string { + const sql = step.toolCall?.input.sql; + return typeof sql === 'string' ? sql : ''; + } + + function sqlFromResult(step: TraceStep): string { + const id = step.toolResult?.id; + if (!id) return ''; + const toolCall = props.message.traceSteps.find( + item => item.type === 'tool_call' && item.toolCall?.id === id, + ); + return toolCall ? sqlFromStep(toolCall) : ''; + }