Skip to content
Draft
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 config/defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
"summary_ai_endpoint": "https://api.openai.com/v1",
"summary_ai_model": "gpt-4o-mini",
"summary_ai_system_prompt": "You are a summarizer. Generate a concise summary of the given text. Output ONLY the summary, nothing else.",
"label_enabled": false,
"label_provider": "local",
"label_ai_api_key": "You are a label generator. Create relevant labels for the given text. Output ONLY the labels, separated by commas.",
"label_ai_endpoint": "https://api.openai.com/v1",
"label_ai_model": "gpt-4o-mini",
"label_ai_system_prompt": "",
"label_show_in_list": true,
"label_max_count": "5",
"auto_cleanup_enabled": false,
"max_cache_size_mb": 20,
"max_article_age_days": 30,
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json.md5
Original file line number Diff line number Diff line change
@@ -1 +1 @@
834c0345d3ff6aba5fac51233310fc09
dd3b03f9de741974ab7e4ae31fcb7c22
34 changes: 34 additions & 0 deletions frontend/src/components/article/ArticleContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import VideoPlayer from './parts/VideoPlayer.vue';
import { useArticleSummary } from '@/composables/article/useArticleSummary';
import { useArticleTranslation } from '@/composables/article/useArticleTranslation';
import { useArticleRendering } from '@/composables/article/useArticleRendering';
import { useArticleLabels } from '@/composables/article/useArticleLabels';
import {
extractTextWithPlaceholders,
restorePreservedElements,
Expand Down Expand Up @@ -45,20 +46,29 @@ const {

const { translationSettings, loadTranslationSettings } = useArticleTranslation();

// Use composable for labels
const { labelSettings, loadLabelSettings, labelArticle, labelingArticles } = useArticleLabels();

// Use composable for enhanced rendering (math formulas, etc.)
const { enhanceRendering, renderMathFormulas, highlightCodeBlocks } = useArticleRendering();

// Computed properties for easier access
const summaryEnabled = computed(() => summarySettings.value.enabled);
const translationEnabled = computed(() => translationSettings.value.enabled);
const targetLanguage = computed(() => translationSettings.value.targetLang);
const labelEnabled = computed(() => labelSettings.value.enabled);

// Current article summary
const summaryResult = ref<SummaryResult | null>(null);
const isLoadingSummary = computed(() =>
props.article ? isSummaryLoading(props.article.id) : false
);

// Computed to check if article is being labeled
const isLabelingArticle = computed(() =>
props.article ? labelingArticles.value.has(props.article.id) : false
);

// Additional state for summary translation
const translatedSummary = ref('');
const isTranslatingSummary = ref(false);
Expand All @@ -73,6 +83,7 @@ const lastTranslatedArticleId = ref<number | null>(null);
async function loadSettings() {
await loadSummarySettings();
await loadTranslationSettings();
await loadLabelSettings();
}

// Translate text using the API
Expand Down Expand Up @@ -103,6 +114,17 @@ async function translateText(text: string): Promise<string> {
return '';
}

// Check if article has labels
function hasLabels(labelsJson: string | undefined): boolean {
if (!labelsJson) return false;
try {
const parsed = JSON.parse(labelsJson);
return Array.isArray(parsed) && parsed.length > 0;
} catch {
return false;
}
}

// Generate summary for the current article
async function generateSummary(article: Article) {
if (!summaryEnabled.value || !article) {
Expand Down Expand Up @@ -270,6 +292,11 @@ watch(
lastTranslatedArticleId.value = null; // Reset translation tracking

if (props.article) {
// Auto-generate labels if enabled and article has no labels
if (labelEnabled.value && !hasLabels(props.article.labels)) {
labelArticle(props.article);
}

if (summaryEnabled.value) {
generateSummary(props.article);
}
Expand Down Expand Up @@ -310,6 +337,11 @@ onMounted(async () => {
enhanceRendering('.prose-content');
}

// Auto-generate labels if enabled and article has no labels
if (labelEnabled.value && !hasLabels(props.article.labels)) {
labelArticle(props.article);
}

if (summaryEnabled.value && props.articleContent) {
generateSummary(props.article);
}
Expand All @@ -331,6 +363,8 @@ onMounted(async () => {
:translated-title="translatedTitle"
:is-translating-title="isTranslatingTitle"
:translation-enabled="translationEnabled"
:label-enabled="labelEnabled"
:is-labeling="isLabelingArticle"
/>

<!-- Audio Player (if article has audio) -->
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/components/article/ArticleItem.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { PhEyeSlash, PhStar, PhClockCountdown } from '@phosphor-icons/vue';
import type { Article } from '@/types/models';
import { formatDate as formatDateUtil } from '@/utils/date';
import ArticleLabels from './parts/ArticleLabels.vue';

interface Props {
article: Article;
Expand All @@ -18,6 +20,17 @@ const emit = defineEmits<{
}>();

const { t, locale } = useI18n();
const showLabels = ref(true);

onMounted(async () => {
try {
const res = await fetch('/api/settings');
const settings = await res.json();
showLabels.value = settings.label_show_in_list === 'true';
} catch (e) {
console.error('Failed to load label settings:', e);
}
});

function formatDate(dateStr: string): string {
return formatDateUtil(dateStr, locale.value === 'zh-CN' ? 'zh-CN' : 'en-US');
Expand Down Expand Up @@ -99,6 +112,14 @@ function handleImageError(event: Event) {
<span class="whitespace-nowrap">{{ formatDate(article.published_at) }}</span>
</div>
</div>

<ArticleLabels
v-if="showLabels && article.labels"
:labelsJson="article.labels"
:maxDisplay="3"
size="sm"
class="mt-1"
/>
</div>
</div>
</template>
Expand Down
43 changes: 42 additions & 1 deletion frontend/src/components/article/ArticleList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import ArticleFilterModal from '../modals/filter/ArticleFilterModal.vue';
import ArticleItem from './ArticleItem.vue';
import { useArticleTranslation } from '@/composables/article/useArticleTranslation';
import { useArticleLabels } from '@/composables/article/useArticleLabels';
import { useArticleFilter } from '@/composables/article/useArticleFilter';
import { useArticleActions } from '@/composables/article/useArticleActions';
import type { Article } from '@/types/models';
Expand Down Expand Up @@ -46,6 +47,15 @@ const {
cleanup: cleanupTranslation,
} = useArticleTranslation();

const {
labelSettings,
loadLabelSettings,
setupIntersectionObserver: setupLabelObserver,
observeArticle: observeLabelArticle,
handleLabelSettingsChange,
cleanup: cleanupLabels,
} = useArticleLabels();

const {
activeFilters,
filteredArticlesFromServer,
Expand All @@ -68,6 +78,7 @@ const filteredArticles = computed(() => {
// Load settings and setup
onMounted(async () => {
await loadTranslationSettings();
await loadLabelSettings();

try {
const res = await fetch('/api/settings');
Expand All @@ -78,6 +89,11 @@ onMounted(async () => {
if (translationSettings.value.enabled && listRef.value) {
setupIntersectionObserver(listRef.value, store.articles);
}

// Set up intersection observer for auto-labeling
if (labelSettings.value.enabled && listRef.value) {
setupLabelObserver(listRef.value, store.articles);
}
} catch (e) {
console.error('Error loading settings:', e);
}
Expand All @@ -87,6 +103,8 @@ onMounted(async () => {
'translation-settings-changed',
onTranslationSettingsChanged as EventListener
);
// Listen for label settings changes
window.addEventListener('label-settings-changed', onLabelSettingsChanged as EventListener);
// Listen for default view mode changes
window.addEventListener('default-view-mode-changed', onDefaultViewModeChanged as EventListener);
// Listen for refresh articles events
Expand Down Expand Up @@ -123,10 +141,12 @@ watch(

onBeforeUnmount(() => {
cleanupTranslation();
cleanupLabels();
window.removeEventListener(
'translation-settings-changed',
onTranslationSettingsChanged as EventListener
);
window.removeEventListener('label-settings-changed', onLabelSettingsChanged as EventListener);
window.removeEventListener(
'default-view-mode-changed',
onDefaultViewModeChanged as EventListener
Expand All @@ -139,6 +159,8 @@ interface CustomEventDetail {
mode?: string;
enabled?: boolean;
targetLang?: string;
provider?: string;
maxCount?: number;
}

// Event handlers
Expand All @@ -162,6 +184,19 @@ function onTranslationSettingsChanged(e: Event): void {
}
}

function onLabelSettingsChanged(e: Event): void {
const customEvent = e as CustomEvent<CustomEventDetail>;
const { enabled, provider, maxCount } = customEvent.detail;
if (enabled !== undefined && provider && maxCount !== undefined) {
handleLabelSettingsChange(enabled, provider, maxCount);

// Re-setup observer if needed
if (enabled && listRef.value) {
setupLabelObserver(listRef.value, store.articles);
}
}
}

function onRefreshArticles(): void {
store.fetchArticles();
}
Expand Down Expand Up @@ -194,6 +229,12 @@ function selectArticle(article: Article): void {
}
}

// Observe article for both translation and labels
function observeArticleElement(el: Element | null): void {
observeArticle(el);
observeLabelArticle(el);
}

// Scrolling handler
function handleScroll(e: Event): void {
const target = e.target as HTMLElement;
Expand Down Expand Up @@ -333,7 +374,7 @@ async function clearReadLater(): Promise<void> {
:isActive="store.currentArticleId === article.id"
@click="selectArticle(article)"
@contextmenu="(e) => showArticleContextMenu(e, article)"
@observeElement="observeArticle"
@observeElement="observeArticleElement"
/>

<div
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/components/article/parts/ArticleLabels.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue';
import { PhTag } from '@phosphor-icons/vue';

interface Props {
labelsJson?: string;
maxDisplay?: number;
size?: 'sm' | 'md';
}

const props = withDefaults(defineProps<Props>(), {
labelsJson: '[]',
maxDisplay: 3,
size: 'sm',
});

const labels = computed(() => {
try {
const parsed = JSON.parse(props.labelsJson);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});

const displayedLabels = computed(() => {
return labels.value.slice(0, props.maxDisplay);
});

const hiddenCount = computed(() => {
return labels.value.length - props.maxDisplay;
});
</script>

<template>
<div v-if="labels.length > 0" class="label-badges">
<div class="label-badge" v-for="label in displayedLabels" :key="label" :class="size">
<PhTag :size="size === 'sm' ? 10 : 12" class="shrink-0" />
<span class="label-text">{{ label }}</span>
</div>
<div v-if="hiddenCount > 0" class="label-badge more-badge" :class="size">
<span class="label-text">+{{ hiddenCount }}</span>
</div>
</div>
</template>

<style scoped>
.label-badges {
@apply flex flex-wrap gap-1 items-center;
}

.label-badge {
@apply flex items-center gap-0.5 px-1.5 py-0.5 rounded-full border border-border bg-bg-tertiary text-text-secondary;
transition: all 0.2s ease;
}

.label-badge.sm {
@apply text-[9px] sm:text-[10px];
}

.label-badge.md {
@apply text-xs sm:text-sm px-2 py-1;
}

.label-badge:hover {
@apply bg-bg-tertiary border-accent;
}

.label-text {
@apply truncate max-w-[60px] sm:max-w-[100px];
}

.label-badge.md .label-text {
@apply max-w-[80px] sm:max-w-[120px];
}

.more-badge {
@apply bg-bg-secondary border-dashed;
}
</style>
Loading