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
8 changes: 8 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,14 @@ docker run -d --name yume -p 8079:8079 --env-file .env yume
- Vue.js: Follow Vue 3 Composition API conventions
- Indentation: Use IDE defaults (IntelliJ IDEA for Kotlin, VS Code for Vue.js)

**Kotlin Best Practices**:
- **Use `runCatching` for Result types**: Never manually wrap try-catch with Result.success/failure. Use `runCatching { ... }` which directly returns `Result<T>`
- ❌ Wrong: `try { Result.success(doSomething()) } catch (e: Exception) { Result.failure(e) }`
- ✅ Correct: `runCatching { doSomething() }`
- **Use Result combinators**: Chain operations with `onSuccess`, `onFailure`, `fold`, `getOrNull`, etc. instead of calling `result.isSuccess`
- **Early returns in functions**: Use guard clauses to exit early, especially for validation and disabled features
- **Leverage HTTP client exceptions**: Spring's RestClient throws exceptions for non-2xx responses, use runCatching to capture them

## Important Notes for Agents

1. **Trust these instructions**: Only search for additional information if these instructions are incomplete or incorrect
Expand Down
22 changes: 22 additions & 0 deletions ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
>
📅 Day Planner
</button>
<button
class="tab"
:class="{ active: activeTab === 'preferences' }"
@click="switchTab('preferences')"
>
⚙️ Preferences
</button>
</div>

<!-- Memory Section -->
Expand Down Expand Up @@ -83,6 +90,9 @@
<!-- Day Planner Section -->
<DayPlanner v-if="activeTab === 'planner'" />

<!-- Preferences Section -->
<PreferencesPane v-if="activeTab === 'preferences'" />

<!-- Interaction Detail Modal -->
<InteractionDetailModal
v-if="selectedInteraction"
Expand All @@ -101,6 +111,7 @@ import TaskItem from './components/TaskItem.vue'
import InteractionItem from './components/InteractionItem.vue'
import InteractionDetailModal from './components/InteractionDetailModal.vue'
import DayPlanner from './components/DayPlanner.vue'
import PreferencesPane from './components/PreferencesPane.vue'
import SchedulerRunsPanel from './components/SchedulerRunsPanel.vue'

export default {
Expand All @@ -111,6 +122,7 @@ export default {
InteractionItem,
InteractionDetailModal,
DayPlanner,
PreferencesPane,
SchedulerRunsPanel
},
data() {
Expand Down Expand Up @@ -219,12 +231,22 @@ export default {
} else if (tab === 'interactions' && this.interactions.length === 0) {
this.loadInteractions()
}
},
handleTabFromUrl() {
const urlParams = new URLSearchParams(window.location.search)
const tab = urlParams.get('tab')
if (tab) {
this.activeTab = tab
}
}
},
async mounted() {
// Check if we're returning from OAuth callback
const hasCallback = this.handleOAuthCallback()

// Check for tab parameter in URL
this.handleTabFromUrl()

// Check authentication status
this.isAuthenticated = apiService.isAuthenticated()

Expand Down
221 changes: 221 additions & 0 deletions ui/src/components/PreferencesPane.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<template>
<div class="preferences">
<h2>⚙️ Preferences</h2>

<div class="preferences-section">
<h3>Strava Integration</h3>

<div v-if="stravaConnected" class="strava-connected">
<div class="status-item">
<div class="status-indicator connected"></div>
<div>
<p class="status-label">Connected</p>
<p class="athlete-name">{{ stravaAthleteName }}</p>
</div>
</div>
<button @click="disconnectStrava" class="button button-danger">
Disconnect Strava
</button>
</div>

<div v-else class="strava-disconnected">
<p>Strava integration allows Yume to fetch and analyze your cycling activities.</p>
<button @click="startStravaAuth" class="button button-primary" :disabled="loading">
{{ loading ? '🔄 Redirecting to Strava...' : '🔗 Connect with Strava' }}
</button>
</div>

<div v-if="stravaError" class="error-message">
{{ stravaError }}
</div>
</div>
</div>
</template>

<script>
import { apiService } from '../services/api'

export default {
name: 'PreferencesPane',
data() {
return {
stravaConnected: false,
stravaAthleteName: '',
stravaError: null,
loading: false
}
},
mounted() {
this.loadStravaStatus()
},
methods: {
async loadStravaStatus() {
try {
const status = await apiService.getStravaStatus()
this.stravaConnected = status.connected
if (status.connected) {
this.stravaAthleteName = status.athleteName || 'Connected'
}
this.stravaError = null
} catch (error) {
console.error('Failed to load Strava status:', error)
this.stravaError = null // Don't show error if not connected
}
},
startStravaAuth() {
// Get the authorization URL from the backend
this.loading = true
this.stravaError = null

apiService.getStravaAuthorizeUrl()
.then(response => {
// Redirect to Strava OAuth
window.location.href = response.url
})
.catch(error => {
this.stravaError = 'Failed to start Strava authorization: ' + error.message
console.error('Failed to get authorize URL:', error)
this.loading = false
})
},
async disconnectStrava() {
if (confirm('Are you sure you want to disconnect your Strava account?')) {
try {
await apiService.disconnectStrava()
this.stravaConnected = false
this.stravaAthleteName = ''
this.stravaError = null
} catch (error) {
this.stravaError = 'Failed to disconnect Strava: ' + error.message
}
}
}
}
}
</script>

<style scoped>
.preferences {
margin-bottom: 2rem;
background: #18181b;
border-radius: 0.75rem;
border: 1px solid #27272a;
padding: 0;
overflow: hidden;
}

.preferences h2 {
margin: 0;
color: #f4f4f5;
font-size: 1.125rem;
padding: 1rem;
background: #1c1c1e;
border-bottom: 1px solid #27272a;
}

.preferences-section {
padding: 1rem;
margin: 0;
}

.preferences-section h3 {
font-size: 0.875rem;
color: #a1a1a6;
margin: 0 0 1rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}

.strava-connected,
.strava-disconnected {
padding: 1rem;
border-radius: 0.5rem;
background: #27272a;
border: 1px solid #3f3f46;
}

.status-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
gap: 12px;
}

.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}

.status-indicator.connected {
background: #4caf50;
}

.status-label {
font-weight: 600;
color: #f4f4f5;
margin: 0 0 0.25rem 0;
font-size: 14px;
}

.athlete-name {
color: #a1a1a6;
margin: 0;
font-size: 14px;
}

.button {
padding: 0.625rem 1rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}

.button-primary {
background: #fc5200;
color: white;
}

.button-primary:hover {
background: #e64800;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(252, 82, 0, 0.2);
}

.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}

.button-danger {
background: #f44336;
color: white;
}

.button-danger:hover {
background: #d32f2f;
}

.error-message {
color: #f87171;
padding: 0.625rem;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 0.375rem;
margin-top: 0.625rem;
font-size: 14px;
}

.strava-disconnected p {
color: #a1a1a6;
margin: 0 0 1rem 0;
font-size: 14px;
line-height: 1.5;
}
</style>
24 changes: 24 additions & 0 deletions ui/src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -320,5 +320,29 @@ export const apiService = {
params.append('days', days)
const response = await api.get(`/scheduler-runs/statistics?${params}`)
return response.data
},

/**
* Get Strava connection status
*/
async getStravaStatus() {
const response = await api.get('/strava/status')
return response.data
},

/**
* Get Strava authorization URL
*/
async getStravaAuthorizeUrl() {
const response = await api.get('/strava/oauth/authorize-url')
return response.data
},

/**
* Disconnect Strava account
*/
async disconnectStrava() {
const response = await api.post('/strava/disconnect')
return response.data
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package eu.sendzik.yume.agent

import dev.langchain4j.service.SystemMessage
import dev.langchain4j.service.UserMessage
import dev.langchain4j.service.V
import dev.langchain4j.service.spring.AiService
import dev.langchain4j.service.spring.AiServiceWiringMode
import eu.sendzik.yume.agent.model.BasicUserInteractionAgentResult
import eu.sendzik.yume.agent.model.EventTriggeredAgentResult

@AiService(
wiringMode = AiServiceWiringMode.EXPLICIT,
chatModel = "sportsChatModel",
tools = ["stravaActivityTools"],
)
interface SportsActivityAgent {
@SystemMessage(fromResource = "prompt/sports-user-message-system-message.txt")
fun handleUserMessage(
@UserMessage query: String,
@V("systemPromptPrefix") yumeSystemPromptPrefix: String,
@V("additionalInformation") additionalInformation: String,
): BasicUserInteractionAgentResult

@SystemMessage(fromResource = "prompt/sports-activity-system-message.txt")
fun handleSportsActivity(
@UserMessage activityDetails: String,
@V("systemPromptPrefix") yumeSystemPromptPrefix: String,
@V("additionalInformation") additionalInformation: String,
): EventTriggeredAgentResult

@SystemMessage(fromResource = "prompt/sports-geofence-system-message.txt")
fun handleGeofenceEvent(
@UserMessage query: String,
@V("systemPromptPrefix") yumeSystemPromptPrefix: String,
@V("additionalInformation") additionalInformation: String,
): EventTriggeredAgentResult

@SystemMessage(fromResource = "prompt/sports-scheduler-system-message.txt")
fun handleScheduledEvent(
@UserMessage query: String,
@V("systemPromptPrefix") yumeSystemPromptPrefix: String,
@V("additionalInformation") additionalInformation: String,
): EventTriggeredAgentResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ package eu.sendzik.yume.agent.model
enum class YumeAgentType(val typeName: String) {
KITCHEN_OWL("kitchen-owl"),
PUBLIC_TRANSPORT("public-transport"),
SPORTS("sports"),
GENERIC("generic")
}
Loading