feat(briefing): hard-cut tear-down
Backend: - Delete briefing services (pipeline, scheduler, conversations, profile, tools) - Delete routes/briefing.py + remove blueprint registration - Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting) - Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific) - Strip briefing scheduler hooks from app.py - Strip briefing scheduler call from routes/settings.py - Update test imports (test_rss_service, test_tz_helpers) Frontend: - Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow - Strip /briefing route + nav links (AppHeader, KnowledgeView) - Strip Settings → Briefing tab + state + functions + imports - Strip briefing-intermediate handling from ChatMessage - Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree) - Drop unused useSettingsStore from AppHeader The Android BriefingScreen lives in a separate repo and is not touched here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
@@ -62,7 +62,7 @@ const appVersion = ref('dev');
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
@@ -72,7 +72,6 @@ function _loadTabContent(tab: string) {
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
}
|
||||
if (tab === "briefing") loadBriefingTab();
|
||||
if (tab === "voice") loadVoiceTab();
|
||||
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||||
}
|
||||
@@ -291,155 +290,6 @@ async function removeMemberFromGroup(groupId: number, userId: number) {
|
||||
await loadGroupsPanel();
|
||||
}
|
||||
|
||||
// Briefing settings
|
||||
const briefingConfig = ref<BriefingConfig>({
|
||||
enabled: false,
|
||||
locations: {},
|
||||
use_caldav_event_locations: false,
|
||||
work_days: [1, 2, 3, 4, 5],
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
});
|
||||
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||||
const briefingSaving = ref(false);
|
||||
const briefingSaved = ref(false);
|
||||
const briefingGeocoding = ref<Record<string, boolean>>({});
|
||||
const briefingGeoError = ref<Record<string, string>>({});
|
||||
const newFeedUrl = ref('');
|
||||
const newFeedCategory = ref('');
|
||||
const addingFeed = ref(false);
|
||||
const refreshingFeeds = ref(false);
|
||||
const briefingIncludeTopics = ref<string[]>([]);
|
||||
const briefingExcludeTopics = ref<string[]>([]);
|
||||
|
||||
function _parseTopics(raw: unknown): string[] {
|
||||
try {
|
||||
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(val) ? val.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBriefingTab() {
|
||||
briefingConfig.value = await getBriefingConfig();
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
|
||||
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
|
||||
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
|
||||
}
|
||||
|
||||
async function saveIncludeTopics(topics: string[]) {
|
||||
briefingIncludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
async function saveExcludeTopics(topics: string[]) {
|
||||
briefingExcludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
|
||||
async function fetchTopicSuggestions(q: string): Promise<string[]> {
|
||||
if (!q) return _STANDARD_TOPICS;
|
||||
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
|
||||
}
|
||||
|
||||
async function geocodeLocation(key: 'home' | 'work') {
|
||||
const loc = briefingConfig.value.locations[key];
|
||||
if (!loc?.address?.trim()) return;
|
||||
briefingGeocoding.value[key] = true;
|
||||
briefingGeoError.value[key] = '';
|
||||
try {
|
||||
const result = await geocodeAddress(loc.address.trim());
|
||||
if (result) {
|
||||
briefingConfig.value.locations[key] = {
|
||||
...loc,
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
};
|
||||
briefingGeoError.value[key] = '';
|
||||
} else {
|
||||
briefingGeoError.value[key] = 'Location not found — check the address';
|
||||
}
|
||||
} catch {
|
||||
briefingGeoError.value[key] = 'Geocoding failed';
|
||||
} finally {
|
||||
briefingGeocoding.value[key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function saveBriefingSettings() {
|
||||
briefingSaving.value = true;
|
||||
briefingSaved.value = false;
|
||||
try {
|
||||
await saveBriefingConfig(briefingConfig.value);
|
||||
briefingSaved.value = true;
|
||||
setTimeout(() => (briefingSaved.value = false), 2000);
|
||||
} catch {
|
||||
toastStore.show('Failed to save briefing settings', 'error');
|
||||
} finally {
|
||||
briefingSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRss() {
|
||||
try {
|
||||
await store.updateSettings({ rss_enabled: store.rssEnabled ? "false" : "true" });
|
||||
} catch {
|
||||
toastStore.show("Failed to update RSS setting", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function addFeed() {
|
||||
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||||
addingFeed.value = true;
|
||||
try {
|
||||
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
|
||||
briefingFeeds.value.push(feed);
|
||||
newFeedUrl.value = '';
|
||||
newFeedCategory.value = '';
|
||||
} catch (err: any) {
|
||||
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error');
|
||||
} finally {
|
||||
addingFeed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFeed(id: number) {
|
||||
await deleteBriefingFeed(id);
|
||||
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
|
||||
}
|
||||
|
||||
async function refreshFeeds() {
|
||||
if (refreshingFeeds.value) return;
|
||||
refreshingFeeds.value = true;
|
||||
try {
|
||||
const result = await refreshBriefingFeeds();
|
||||
// Reload feed list so last_fetched_at updates
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''} — ${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toastStore.show('Failed to refresh feeds', 'error');
|
||||
} finally {
|
||||
refreshingFeeds.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function feedAge(isoStr: string | null): string {
|
||||
if (!isoStr) return 'never fetched';
|
||||
const diff = Date.now() - new Date(isoStr).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
// Chat retention
|
||||
const chatRetentionDays = ref(90);
|
||||
@@ -1465,7 +1315,7 @@ function formatUserDate(iso: string): string {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'voice', 'apikeys']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
@@ -2115,219 +1965,6 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Briefing ── -->
|
||||
<div v-show="activeTab === 'briefing'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Daily Briefing</h2>
|
||||
<p class="section-desc">
|
||||
Configure your daily briefing — a conversation that summarises your day,
|
||||
weather, and RSS feeds on a schedule.
|
||||
</p>
|
||||
|
||||
<!-- Enable -->
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.enabled" />
|
||||
Enable Daily Briefing
|
||||
</label>
|
||||
<p class="field-hint">Start daily briefings at the configured slots.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Locations -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Locations</h2>
|
||||
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
|
||||
|
||||
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
|
||||
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
|
||||
<div class="briefing-input-group">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
|
||||
:value="briefingConfig.locations[key]?.address ?? ''"
|
||||
@input="(e) => {
|
||||
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
|
||||
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
|
||||
}"
|
||||
/>
|
||||
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
|
||||
{{ briefingGeocoding[key] ? 'Looking up…' : 'Look up' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
|
||||
✓ {{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
|
||||
</div>
|
||||
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-field" style="margin-top: 0.75rem">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
|
||||
Also check CalDAV event locations
|
||||
</label>
|
||||
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top: 1rem">
|
||||
<label class="field-label">Temperature unit</label>
|
||||
<div class="briefing-unit-toggle">
|
||||
<button
|
||||
type="button"
|
||||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
|
||||
@click="briefingConfig.temp_unit = 'C'"
|
||||
>°C</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
|
||||
@click="briefingConfig.temp_unit = 'F'"
|
||||
>°F</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Slots -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled Slots</h2>
|
||||
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
|
||||
<div class="briefing-slot-list">
|
||||
<div
|
||||
v-for="[key, label, localTime] in ([
|
||||
['compilation', 'Morning briefing', '4:00 am'],
|
||||
['morning', 'Office check-in', '8:00 am'],
|
||||
['midday', 'Midday update', '12:00 pm'],
|
||||
['afternoon', 'End of day', '4:00 pm'],
|
||||
] as const)"
|
||||
:key="key"
|
||||
class="briefing-slot-row"
|
||||
>
|
||||
<div class="briefing-slot-info">
|
||||
<span class="briefing-slot-label">{{ label }}</span>
|
||||
<span class="briefing-slot-time">{{ localTime }}</span>
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top: 0.5rem">
|
||||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not configured — set in General settings)' }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- RSS toggle -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>RSS / News</h2>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" :checked="store.rssEnabled" @change="toggleRss" />
|
||||
Enable RSS feeds
|
||||
</label>
|
||||
<p class="field-hint">Subscribe to RSS/Atom feeds and include news in your briefings.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RSS Feeds -->
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<div class="briefing-feeds-header">
|
||||
<div>
|
||||
<h2>RSS Feeds</h2>
|
||||
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="briefingFeeds.length"
|
||||
class="btn-secondary briefing-refresh-btn"
|
||||
@click="refreshFeeds"
|
||||
:disabled="refreshingFeeds"
|
||||
title="Fetch latest items from all feeds"
|
||||
>{{ refreshingFeeds ? 'Refreshing…' : 'Refresh all' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
|
||||
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
|
||||
<div class="briefing-feed-info">
|
||||
<div class="briefing-feed-title-row">
|
||||
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||||
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
|
||||
</div>
|
||||
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
|
||||
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</span>
|
||||
</div>
|
||||
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
|
||||
|
||||
<div class="briefing-add-feed-form">
|
||||
<div class="briefing-add-feed-inputs">
|
||||
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
|
||||
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
|
||||
</div>
|
||||
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
|
||||
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- News Preferences -->
|
||||
<section v-if="store.rssEnabled" class="settings-section full-width">
|
||||
<h2>News Preferences</h2>
|
||||
<p class="section-desc">
|
||||
Tell the briefing what topics you care about. Topics are matched against
|
||||
classified RSS items before each briefing runs.
|
||||
</p>
|
||||
<div class="settings-field">
|
||||
<label class="field-label">Interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingIncludeTopics"
|
||||
placeholder="e.g. technology, science, local"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveIncludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<div class="settings-field" style="margin-top: 0.75rem">
|
||||
<label class="field-label">Not interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingExcludeTopics"
|
||||
placeholder="e.g. sports, celebrity"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveExcludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
|
||||
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
|
||||
<p class="field-hint" style="margin-top: 0.35rem">
|
||||
technology · science · politics · business · health · environment ·
|
||||
local · entertainment · sports · other
|
||||
</p>
|
||||
<p class="field-hint">Custom terms are also accepted.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Notifications</h2>
|
||||
<div class="checkbox-field">
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.notifications" />
|
||||
Push notification when each briefing slot is ready
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
|
||||
{{ briefingSaved ? 'Saved ✓' : briefingSaving ? 'Saving…' : 'Save Briefing Settings' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Voice ── -->
|
||||
<div v-show="activeTab === 'voice'" class="settings-grid">
|
||||
|
||||
<section class="settings-section full-width">
|
||||
|
||||
Reference in New Issue
Block a user