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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
import io.agentscope.core.state.SimpleSessionKey;
import io.github.malonetalk.convertor.handler.ToolResultHandler;
import io.github.malonetalk.dto.ChatStreamEvent;
import io.github.malonetalk.dto.SessionDatasourceBinding;
import io.github.malonetalk.dto.SessionInfo;
import io.github.malonetalk.dto.TurnItem;
import io.github.malonetalk.enums.ChatStreamEventType;
import io.github.malonetalk.mapper.SessionDatasourceMapper;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
Expand All @@ -47,22 +49,27 @@
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
public class SessionService {

private final DataSource dataSource;
private final SessionDatasourceMapper sessionDatasourceMapper;
private final Map<String, Session> sessionCache = new ConcurrentHashMap<>();

public SessionService(DataSource dataSource) {
public SessionService(DataSource dataSource, SessionDatasourceMapper sessionDatasourceMapper) {
this.dataSource = dataSource;
this.sessionDatasourceMapper = sessionDatasourceMapper;
}

public Session getOrCreateSession(String sessionId) {
return sessionCache.computeIfAbsent(
sessionId,
k -> new MysqlSession(dataSource, "data_agent", "agentscope_sessions", false));
return sessionCache.computeIfAbsent(sessionId, k -> createMysqlSession());
}

private MysqlSession createMysqlSession() {
return new MysqlSession(dataSource, "data_agent", "agentscope_sessions", false);
}

public List<Msg> getSessionDebug(String sessionId) {
Expand Down Expand Up @@ -185,30 +192,28 @@ private static ChatStreamEvent thinkingEvent(String thinking) {
.build();
}

// TODO 2026/08/11 这里保留agentscope 的 MysqlSession.delete() ,虽然大概率不走 Spring 事务,
// 但注解至少明确了这是跨表操作的语义边界,后续如果有人接手知道这里需要考虑事务一致性。后期有时间回来完善这里的双表操作的事务
@Transactional
public void clearSession(String sessionId) {
Session session = sessionCache.remove(sessionId);
if (session != null) {
session.delete(SimpleSessionKey.of(sessionId));
}
// 一并清理会话的数据源绑定,避免孤儿绑定。
try (Connection conn = dataSource.getConnection();
PreparedStatement ps =
conn.prepareStatement(
"DELETE FROM session_datasource WHERE session_id = ?")) {
ps.setString(1, sessionId);
ps.executeUpdate();
} catch (Exception e) {
log.error("Error deleting session datasource binding", e);
if (session == null) {
session = createMysqlSession();
}
session.delete(SimpleSessionKey.of(sessionId));
sessionDatasourceMapper.deleteBySessionId(sessionId);
}

@Transactional
public void clearAllSessions() {
sessionCache.clear();
Set<String> sessionIds = Set.copyOf(sessionCache.keySet());
for (String sessionId : sessionIds) {
clearSession(sessionId);
}
}

public List<SessionInfo> listSessions() {
MysqlSession session =
new MysqlSession(dataSource, "data_agent", "agentscope_sessions", false);
MysqlSession session = createMysqlSession();
Set<SessionKey> keys = session.listSessionKeys();

if (keys == null || keys.isEmpty()) {
Expand All @@ -230,18 +235,16 @@ public List<SessionInfo> listSessions() {
}

Map<String, String[]> bindings = new HashMap<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement ps =
conn.prepareStatement(
"SELECT sd.session_id, sd.datasource_id, d.name"
+ " FROM session_datasource sd"
+ " LEFT JOIN datasource d ON sd.datasource_id = d.id");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
bindings.put(rs.getString(1), new String[] {rs.getString(2), rs.getString(3)});
}
} catch (Exception e) {
log.error("Error listing session datasource bindings", e);
for (SessionDatasourceBinding row :
sessionDatasourceMapper.listBindingsWithDatasourceName()) {
bindings.put(
row.getSessionId(),
new String[] {
row.getDatasourceId() != null
? String.valueOf(row.getDatasourceId())
: null,
row.getDatasourceName()
});
}

List<SessionInfo> result = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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 <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.dto;

import lombok.Data;

/** {@link io.github.malonetalk.mapper.SessionDatasourceMapper#listBindingsWithDatasourceName()} 的结果行。 */
@Data
public class SessionDatasourceBinding {

private String sessionId;
private Integer datasourceId;
private String datasourceName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
*/
package io.github.malonetalk.mapper;

import io.github.malonetalk.dto.SessionDatasourceBinding;
import io.github.malonetalk.entity.SessionDatasource;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

Expand All @@ -28,4 +30,9 @@ public interface SessionDatasourceMapper {
int insertIfAbsent(SessionDatasource sessionDatasource);

SessionDatasource selectBySessionId(@Param("sessionId") String sessionId);

int deleteBySessionId(@Param("sessionId") String sessionId);

/** 列出所有绑定并关联数据源名称。 */
List<SessionDatasourceBinding> listBindingsWithDatasourceName();
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,16 @@
SELECT * FROM session_datasource WHERE session_id = #{sessionId}
</select>

<delete id="deleteBySessionId">
DELETE FROM session_datasource WHERE session_id = #{sessionId}
</delete>

<select id="listBindingsWithDatasourceName" resultType="io.github.malonetalk.dto.SessionDatasourceBinding">
SELECT sd.session_id AS sessionId,
sd.datasource_id AS datasourceId,
d.name AS datasourceName
FROM session_datasource sd
LEFT JOIN datasource d ON sd.datasource_id = d.id
</select>

</mapper>
4 changes: 4 additions & 0 deletions data-agent-frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export default tseslint.config(
parseFloat: 'readonly',
isNaN: 'readonly',
isFinite: 'readonly',
Event: 'readonly',
MouseEvent: 'readonly',
},
},
rules: {
Expand Down Expand Up @@ -253,6 +255,8 @@ export default tseslint.config(
isFinite: 'readonly',
AbortController: 'readonly',
AbortSignal: 'readonly',
Event: 'readonly',
MouseEvent: 'readonly',
},
},
rules: {
Expand Down
15 changes: 13 additions & 2 deletions data-agent-frontend/src/api/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,11 @@ function authHeaders(): Record<string, string> {
return headers;
}

async function fetchJson<T>(url: string, fallback: string): Promise<T> {
const response = await fetch(url, { headers: authHeaders() });
async function fetchJson<T>(url: string, fallback: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { ...authHeaders(), ...(init?.headers as Record<string, string>) },
});
if (response.status === 401) {
handleUnauthorized();
throw new ApiError('登录已过期,请重新登录', { code: 401 });
Expand Down Expand Up @@ -139,6 +142,14 @@ export async function fetchSessionHistory(sessionId: string): Promise<TurnItem[]
);
}

export async function clearSession(sessionId: string): Promise<void> {
await fetchJson<boolean>(
`/api/agent/session/${encodeURIComponent(sessionId)}`,
'Failed to delete session',
{ method: 'DELETE' },
);
}

export async function* streamChat(
request: ChatRequest,
abortSignal?: AbortSignal,
Expand Down
1 change: 1 addition & 0 deletions data-agent-frontend/src/views/chat/ChatView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
ref="sessionListRef"
:active-session-id="activeSessionId"
@new-session="handleNewSession"
@session-deleted="sessionListRef?.loadList()"
/>
</div>

Expand Down
73 changes: 68 additions & 5 deletions data-agent-frontend/src/views/chat/components/SessionList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { fetchSessionList, type SessionInfo } from '@/api/agent';
import { ElMessage, ElMessageBox } from 'element-plus';
import { fetchSessionList, clearSession, type SessionInfo } from '@/api/agent';

defineProps<{
const props = defineProps<{
activeSessionId: string | null;
}>();

const emit = defineEmits<{
newSession: [];
sessionDeleted: [sessionId: string];
}>();

const router = useRouter();
Expand Down Expand Up @@ -64,6 +66,23 @@
emit('newSession');
}

async function handleDelete(e: MouseEvent, s: SessionInfo) {
e.stopPropagation();
try {
await ElMessageBox.confirm(`确定要删除会话「${s.title || s.sessionId}」吗?`, '提示', {
type: 'warning',
});
} catch {
return;
}
await clearSession(s.sessionId);
ElMessage.success('删除成功');
emit('sessionDeleted', s.sessionId);
if (s.sessionId === props.activeSessionId) {
router.push('/chat');
}
}

defineExpose({ loadList });

onMounted(() => loadList());
Expand All @@ -87,9 +106,16 @@
:class="{ active: s.sessionId === activeSessionId }"
@click="selectSession(s.sessionId)"
>
<div class="session-item__title">{{ s.title || s.sessionId }}</div>
<div class="session-item__ds">{{ s.datasourceName ?? '未绑定数据源' }}</div>
<div class="session-item__time">{{ formatTime(s.lastActiveAt) }}</div>
<div class="session-item__row">
<div class="session-item__main">
<div class="session-item__title">{{ s.title || s.sessionId }}</div>
<div class="session-item__ds">{{ s.datasourceName ?? '未绑定数据源' }}</div>
<div class="session-item__time">{{ formatTime(s.lastActiveAt) }}</div>
</div>
<button class="session-item__delete" @click="e => handleDelete(e, s)" title="删除会话">
×
</button>
</div>
</div>
</div>

Expand Down Expand Up @@ -148,6 +174,43 @@
margin-bottom: 2px;
}

.session-item__row {
display: flex;
align-items: flex-start;
}

.session-item__main {
flex: 1;
min-width: 0;
}

.session-item__delete {
display: none;
flex-shrink: 0;
margin-left: 8px;
margin-top: 2px;
width: 20px;
height: 20px;
line-height: 20px;
font-size: 14px;
color: var(--app-text-muted);
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
text-align: center;
padding: 0;
}

.session-item__delete:hover {
color: #f56c6c;
background: rgba(245, 108, 108, 0.1);
}

.session-item:hover .session-item__delete {
display: block;
}

.session-item:hover {
background: var(--app-bg-hover);
}
Expand Down
Loading