feat: implement AI integration with backend configuration and rule-based enrichment actions
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User } from '../types';
|
||||
import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User, AISettings, PromptTemplate, AITestResult } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Rss,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Clock,
|
||||
Timer,
|
||||
ChevronDown,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SettingsViewProps {
|
||||
@@ -53,6 +54,13 @@ interface BuilderActionsState {
|
||||
variantKey: string;
|
||||
variantValue: string;
|
||||
contentType: string;
|
||||
// AI Actions
|
||||
aiEnrich: boolean;
|
||||
aiPrompt: string;
|
||||
aiVariantKey: string;
|
||||
aiAgentAction: boolean;
|
||||
aiAgentPrompt: string;
|
||||
aiAgentAllowedActions: string[];
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
@@ -63,7 +71,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
onRefreshData,
|
||||
onOpenAddFeed,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'viewer' | 'opml' | 'account'>('feeds');
|
||||
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'ai' | 'viewer' | 'opml' | 'account'>('feeds');
|
||||
const [autoReadBottom, setAutoReadBottom] = useState<boolean>(() => {
|
||||
return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
|
||||
});
|
||||
@@ -72,6 +80,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// AI Settings State
|
||||
const [aiSettings, setAiSettings] = useState<AISettings>({
|
||||
enabled: false,
|
||||
provider: 'openai',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.3,
|
||||
max_tokens: 1000,
|
||||
prompts: [],
|
||||
});
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiTesting, setAiTesting] = useState(false);
|
||||
const [aiTestResult, setAiTestResult] = useState<AITestResult | null>(null);
|
||||
const [aiSaving, setAiSaving] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [editingPrompt, setEditingPrompt] = useState<PromptTemplate | null>(null);
|
||||
|
||||
// Rules State
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(false);
|
||||
@@ -86,7 +112,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
const [variantSubEvent, setVariantSubEvent] = useState<'key_added' | 'value_changed' | 'key_deleted'>('key_added');
|
||||
const [targetVariantKey, setTargetVariantKey] = useState('keywords');
|
||||
const [ruleMatchMode, setRuleMatchMode] = useState<'all' | 'any'>('all');
|
||||
|
||||
|
||||
const [builderConditions, setBuilderConditions] = useState<BuilderCondition[]>([
|
||||
{
|
||||
id: 'cond-1',
|
||||
@@ -108,6 +134,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
variantKey: 'tags',
|
||||
variantValue: '',
|
||||
contentType: 'text',
|
||||
aiEnrich: false,
|
||||
aiPrompt: '',
|
||||
aiVariantKey: 'ai_summary',
|
||||
aiAgentAction: false,
|
||||
aiAgentPrompt: '',
|
||||
aiAgentAllowedActions: ['mark_read', 'mark_starred'],
|
||||
});
|
||||
|
||||
// Edit Feed Modal State
|
||||
@@ -172,6 +204,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
loadAISettings();
|
||||
}, []);
|
||||
|
||||
const loadRules = async () => {
|
||||
@@ -186,6 +219,61 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const loadAISettings = async () => {
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const data = await api.getAISettings();
|
||||
if (data) {
|
||||
setAiSettings(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAISettings = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAiSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
...aiSettings,
|
||||
// Auto-enable if API key or Ollama is configured unless explicitly set
|
||||
enabled: aiSettings.enabled || Boolean(aiSettings.api_key.trim()) || aiSettings.provider === 'ollama',
|
||||
};
|
||||
const updated = await api.updateAISettings(payload);
|
||||
setAiSettings(updated);
|
||||
setMessage('AI Configuration saved successfully.');
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to save AI settings');
|
||||
} finally {
|
||||
setAiSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestAIConnection = async () => {
|
||||
setAiTesting(true);
|
||||
setAiTestResult(null);
|
||||
try {
|
||||
const res = await api.testAIConnection(aiSettings);
|
||||
setAiTestResult(res);
|
||||
if (res.success) {
|
||||
setAiSettings((prev) => ({ ...prev, enabled: true }));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAiTestResult({
|
||||
success: false,
|
||||
message: err.message || 'Connection test failed',
|
||||
latency_ms: 0,
|
||||
model: aiSettings.model,
|
||||
});
|
||||
} finally {
|
||||
setAiTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateRuleModal = () => {
|
||||
setEditingRuleId(null);
|
||||
setRuleName('');
|
||||
@@ -212,6 +300,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
variantKey: 'tags',
|
||||
variantValue: '',
|
||||
contentType: 'text',
|
||||
aiEnrich: false,
|
||||
aiPrompt: aiSettings.prompts?.[0]?.prompt || '',
|
||||
aiVariantKey: 'ai_summary',
|
||||
aiAgentAction: false,
|
||||
aiAgentPrompt: '',
|
||||
aiAgentAllowedActions: ['mark_read', 'mark_starred'],
|
||||
});
|
||||
setSaveDropdownOpen(false);
|
||||
setShowRuleModal(true);
|
||||
@@ -267,6 +361,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
let vKey = 'tags';
|
||||
let vVal = '';
|
||||
let cType = 'text';
|
||||
let aiEnrichVal = false;
|
||||
let aiPromptVal = '';
|
||||
let aiVariantKeyVal = 'ai_summary';
|
||||
let aiAgentVal = false;
|
||||
let aiAgentPromptVal = '';
|
||||
let aiAgentAllowedVal: string[] = ['mark_read', 'mark_starred'];
|
||||
|
||||
rule.actions?.forEach((a) => {
|
||||
if (a.type === 'readability_scrape') scrape = true;
|
||||
@@ -278,6 +378,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
vVal = String(a.params?.value || '');
|
||||
cType = String(a.params?.content_type || 'text');
|
||||
}
|
||||
if (a.type === 'ai_enrich' || a.type === 'ai_summary') {
|
||||
aiEnrichVal = true;
|
||||
aiPromptVal = String(a.params?.prompt || a.params?.custom_prompt || '');
|
||||
aiVariantKeyVal = String(a.params?.variant_key || 'ai_summary');
|
||||
}
|
||||
if (a.type === 'ai_agent_action') {
|
||||
aiAgentVal = true;
|
||||
aiAgentPromptVal = String(a.params?.prompt || '');
|
||||
if (Array.isArray(a.params?.allowed_actions)) {
|
||||
aiAgentAllowedVal = a.params.allowed_actions.map(String);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setBuilderActions({
|
||||
@@ -288,6 +400,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
variantKey: vKey,
|
||||
variantValue: vVal,
|
||||
contentType: cType,
|
||||
aiEnrich: aiEnrichVal,
|
||||
aiPrompt: aiPromptVal || aiSettings.prompts?.[0]?.prompt || '',
|
||||
aiVariantKey: aiVariantKeyVal,
|
||||
aiAgentAction: aiAgentVal,
|
||||
aiAgentPrompt: aiAgentPromptVal,
|
||||
aiAgentAllowedActions: aiAgentAllowedVal,
|
||||
});
|
||||
|
||||
setSaveDropdownOpen(false);
|
||||
@@ -336,6 +454,25 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
},
|
||||
});
|
||||
}
|
||||
if (builderActions.aiEnrich) {
|
||||
compiledActions.push({
|
||||
type: 'ai_enrich',
|
||||
params: {
|
||||
variant_key: builderActions.aiVariantKey.trim() || 'ai_summary',
|
||||
prompt: builderActions.aiPrompt,
|
||||
content_type: 'text',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (builderActions.aiAgentAction) {
|
||||
compiledActions.push({
|
||||
type: 'ai_agent_action',
|
||||
params: {
|
||||
prompt: builderActions.aiAgentPrompt,
|
||||
allowed_actions: builderActions.aiAgentAllowedActions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (compiledActions.length === 0) {
|
||||
alert('Please select at least one action to execute.');
|
||||
@@ -590,6 +727,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
<span>Rules & Hooks ({rules.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'ai' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('ai')}
|
||||
>
|
||||
<Bot size={15} />
|
||||
<span>AI & Integrations</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'viewer' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('viewer')}
|
||||
@@ -997,6 +1142,308 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI & Integrations Tab */}
|
||||
{activeTab === 'ai' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
AI & Integrations
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Configure OpenAI-compatible LLM providers for automated summarization, keyword extraction, and autonomous triage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{aiLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '1rem', color: 'var(--text-muted)' }}>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>Loading AI configuration...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Provider Config Card */}
|
||||
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem', borderBottom: '1px solid var(--border)', paddingBottom: '0.85rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Bot size={18} style={{ color: '#34d399' }} />
|
||||
<h3 className="settings-card-title" style={{ margin: 0 }}>LLM Provider Settings</h3>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiSettings.enabled}
|
||||
onChange={(e) => setAiSettings({ ...aiSettings, enabled: e.target.checked })}
|
||||
style={{ accentColor: '#10b981', width: 16, height: 16 }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: aiSettings.enabled ? '#34d399' : 'var(--text-muted)' }}>
|
||||
{aiSettings.enabled ? 'AI Enabled' : 'AI Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Provider Quick Presets */}
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<label className="form-label" style={{ marginBottom: '0.4rem' }}>Quick Provider Presets</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{[
|
||||
{ id: 'openai', label: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
||||
{ id: 'deepseek', label: 'DeepSeek', base: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
|
||||
{ id: 'gemini', label: 'Google Gemini', base: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash' },
|
||||
{ id: 'ollama', label: 'Ollama (Local)', base: 'http://localhost:11434/v1', model: 'llama3.2' },
|
||||
{ id: 'custom', label: 'Custom Endpoint', base: '', model: '' },
|
||||
].map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`btn-secondary ${aiSettings.provider === p.id ? 'active' : ''}`}
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
borderColor: aiSettings.provider === p.id ? '#10b981' : undefined,
|
||||
color: aiSettings.provider === p.id ? '#34d399' : undefined,
|
||||
background: aiSettings.provider === p.id ? 'rgba(16, 185, 129, 0.08)' : undefined,
|
||||
}}
|
||||
onClick={() => {
|
||||
setAiSettings({
|
||||
...aiSettings,
|
||||
provider: p.id,
|
||||
base_url: p.base || aiSettings.base_url,
|
||||
model: p.model || aiSettings.model,
|
||||
});
|
||||
setAiTestResult(null);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveAISettings}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">API Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={aiSettings.base_url}
|
||||
onChange={(e) => setAiSettings({ ...aiSettings, base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Model Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={aiSettings.model}
|
||||
onChange={(e) => setAiSettings({ ...aiSettings, model: e.target.value })}
|
||||
placeholder="e.g. gpt-4o-mini, deepseek-chat, llama3.2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
||||
<label className="form-label" style={{ margin: 0 }}>API Key</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)', height: 'auto', width: 'auto', padding: '2px 6px' }}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? 'Hide Key' : 'Show Key'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
className="form-input"
|
||||
value={aiSettings.api_key}
|
||||
onChange={(e) => setAiSettings({ ...aiSettings, api_key: e.target.value })}
|
||||
placeholder={aiSettings.provider === 'ollama' ? 'Optional for local Ollama' : 'sk-...'}
|
||||
/>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
Stored securely in local SQLite database. Keys are masked during display.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1.25rem' }}>
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<label className="form-label">Temperature: {aiSettings.temperature}</label>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={aiSettings.temperature}
|
||||
onChange={(e) => setAiSettings({ ...aiSettings, temperature: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', accentColor: '#10b981' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
|
||||
<span>0.0 (Precise / Deterministic)</span>
|
||||
<span>1.0 (Creative)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Max Tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={aiSettings.max_tokens}
|
||||
onChange={(e) => setAiSettings({ ...aiSettings, max_tokens: parseInt(e.target.value) || 1000 })}
|
||||
min={100}
|
||||
max={8192}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Test Banner */}
|
||||
{aiTestResult && (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 6,
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.82rem',
|
||||
background: aiTestResult.success ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
border: `1px solid ${aiTestResult.success ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
|
||||
color: aiTestResult.success ? '#34d399' : '#f87171',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{aiTestResult.success ? '✓ Connection Verified' : '✕ Connection Error'}:</strong>{' '}
|
||||
<span>{aiTestResult.message}</span>
|
||||
</div>
|
||||
{aiTestResult.latency_ms > 0 && (
|
||||
<span style={{ fontSize: '0.72rem', opacity: 0.8 }}>{aiTestResult.latency_ms}ms</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={handleTestAIConnection}
|
||||
disabled={aiTesting}
|
||||
>
|
||||
{aiTesting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>Testing Connection...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={14} />
|
||||
<span>Test Connection</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={aiSaving}>
|
||||
{aiSaving ? 'Saving...' : 'Save AI Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Prompt Template Library Card */}
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem', borderBottom: '1px solid var(--border)', paddingBottom: '0.85rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Sparkles size={18} style={{ color: '#38bdf8' }} />
|
||||
<h3 className="settings-card-title" style={{ margin: 0 }}>Prompt Template Library</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
||||
onClick={() => {
|
||||
const newPrompt: PromptTemplate = {
|
||||
id: `custom_${Date.now()}`,
|
||||
name: 'New Custom Prompt',
|
||||
description: 'Custom summarization prompt',
|
||||
prompt: 'Summarize the following in 3 points:\n\nTitle: {{title}}\nContent:\n{{content}}',
|
||||
};
|
||||
const updated = { ...aiSettings, prompts: [...aiSettings.prompts, newPrompt] };
|
||||
setAiSettings(updated);
|
||||
setEditingPrompt(newPrompt);
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
<span>Add Template</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{aiSettings.prompts.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
padding: '0.85rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<strong style={{ fontSize: '0.88rem', color: 'var(--text-main)' }}>{p.name}</strong>
|
||||
{p.is_default && (
|
||||
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
title="Edit Template"
|
||||
onClick={() => setEditingPrompt(p)}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
{!p.is_default && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
title="Delete Template"
|
||||
onClick={() => {
|
||||
const filtered = aiSettings.prompts.filter((item) => item.id !== p.id);
|
||||
const updated = { ...aiSettings, prompts: filtered };
|
||||
setAiSettings(updated);
|
||||
api.updateAISettings(updated);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{p.description && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{p.description}
|
||||
</div>
|
||||
)}
|
||||
<pre style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', background: 'var(--bg-card)', padding: '0.5rem', borderRadius: 4, margin: 0, whiteSpace: 'pre-wrap', maxHeight: 80, overflowY: 'auto' }}>
|
||||
{p.prompt}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Viewer Preferences Tab */}
|
||||
{activeTab === 'viewer' && (
|
||||
<div>
|
||||
@@ -1335,8 +1782,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
>
|
||||
<option value={5}>5 seconds (Fast)</option>
|
||||
<option value={10}>10 seconds (Standard)</option>
|
||||
<option value={15}>15 seconds</option>
|
||||
<option value={30}>30 seconds (Slow)</option>
|
||||
<option value={30}>30 seconds</option>
|
||||
<option value={60}>60 seconds (Slow)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2225,6 +2672,177 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* AI Variant Enrichment Action */}
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={builderActions.aiEnrich}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, aiEnrich: e.target.checked })}
|
||||
style={{ accentColor: '#10b981', width: 16, height: 16, marginTop: 2 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<strong style={{ color: '#34d399' }}>✨ Generate AI Summary / Enrichment</strong>
|
||||
<span style={{ fontSize: '0.65rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.12)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.25)' }}>
|
||||
LLM Powered
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
Run prompt through configured LLM provider and save structured summary to variants.
|
||||
</div>
|
||||
|
||||
{builderActions.aiEnrich && (
|
||||
<div style={{ marginTop: '0.6rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||
Prompt Template Preset
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
onChange={(e) => {
|
||||
const selected = aiSettings.prompts.find((p) => p.id === e.target.value);
|
||||
if (selected) {
|
||||
setBuilderActions({ ...builderActions, aiPrompt: selected.prompt });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">(Select a saved template...)</option>
|
||||
{aiSettings.prompts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} {p.is_default ? '(Default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||
Target Variant Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="ai_summary"
|
||||
value={builderActions.aiVariantKey}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, aiVariantKey: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem', margin: 0 }}>
|
||||
Prompt Instructions (Supports variable tags)
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{['{{title}}', '{{content}}', '{{feed_title}}'].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.65rem', padding: '1px 5px' }}
|
||||
onClick={() => setBuilderActions({ ...builderActions, aiPrompt: builderActions.aiPrompt + ' ' + v })}
|
||||
>
|
||||
+{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}
|
||||
value={builderActions.aiPrompt}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, aiPrompt: e.target.value })}
|
||||
placeholder="You are an expert technical editor. Summarize the following in 3 points:\n\nTitle: {{title}}\nContent:\n{{content}}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* AI Autonomous Triage Action */}
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={builderActions.aiAgentAction}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, aiAgentAction: e.target.checked })}
|
||||
style={{ accentColor: '#38bdf8', width: 16, height: 16, marginTop: 2 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<strong style={{ color: '#38bdf8' }}>🤖 AI Autonomous Triage (Action Recommender)</strong>
|
||||
<span style={{ fontSize: '0.65rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(56, 189, 248, 0.12)', color: '#38bdf8', border: '1px solid rgba(56, 189, 248, 0.25)' }}>
|
||||
Decision Agent
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
AI evaluates article against your policy and triggers actions restricted strictly to the allowed action pool.
|
||||
</div>
|
||||
|
||||
{builderActions.aiAgentAction && (
|
||||
<div style={{ marginTop: '0.6rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div style={{ marginBottom: '0.6rem' }}>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||
Policy Prompt / Decision Criteria
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
value={builderActions.aiAgentPrompt}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, aiAgentPrompt: e.target.value })}
|
||||
placeholder="e.g. If this post discusses critical Kubernetes security alerts or breaking CVEs, star it and tag 'security-alert'. If it is promotional marketing fluff, mark as read."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem', marginBottom: '0.35rem' }}>
|
||||
Restricted Allowed Action Pool (Security Guardrail)
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.4rem', fontSize: '0.75rem' }}>
|
||||
{[
|
||||
{ id: 'mark_starred', label: '⭐ Star Post' },
|
||||
{ id: 'mark_read', label: '✓ Mark as Read' },
|
||||
{ id: 'readability_scrape', label: '⚡ Scrape Full Text' },
|
||||
{ id: 'set_variant:tags:to_share', label: '🏷️ Tag: to_share' },
|
||||
{ id: 'set_variant:tags:urgent', label: '🏷️ Tag: urgent' },
|
||||
{ id: 'set_variant:tags:security-alert', label: '🏷️ Tag: security-alert' },
|
||||
{ id: 'set_variant:tags:k8s+gpu', label: '🏷️ Tag: k8s+gpu' },
|
||||
].map((act) => {
|
||||
const isChecked = builderActions.aiAgentAllowedActions.includes(act.id);
|
||||
return (
|
||||
<label key={act.id} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBuilderActions({
|
||||
...builderActions,
|
||||
aiAgentAllowedActions: [...builderActions.aiAgentAllowedActions, act.id],
|
||||
});
|
||||
} else {
|
||||
setBuilderActions({
|
||||
...builderActions,
|
||||
aiAgentAllowedActions: builderActions.aiAgentAllowedActions.filter((a) => a !== act.id),
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{ accentColor: '#38bdf8', width: 14, height: 14 }}
|
||||
/>
|
||||
<span>{act.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2273,6 +2891,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
builderActions.markStarred && 'Star Post',
|
||||
builderActions.markRead && 'Mark Read',
|
||||
builderActions.setVariant && `Set [${builderActions.variantKey || 'tags'}]`,
|
||||
builderActions.aiEnrich && `AI Enrich [${builderActions.aiVariantKey || 'ai_summary'}]`,
|
||||
builderActions.aiAgentAction && 'AI Autonomous Triage',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'No Actions Selected'}
|
||||
@@ -2416,6 +3036,109 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Prompt Template Modal */}
|
||||
{editingPrompt && (
|
||||
<div className="modal-overlay" onClick={() => setEditingPrompt(null)}>
|
||||
<div className="modal-card" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Sparkles size={17} style={{ color: '#38bdf8' }} />
|
||||
<h3>Edit Prompt Template</h3>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setEditingPrompt(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const existingIdx = aiSettings.prompts.findIndex((p) => p.id === editingPrompt.id);
|
||||
let updatedList = [...aiSettings.prompts];
|
||||
if (existingIdx >= 0) {
|
||||
updatedList[existingIdx] = editingPrompt;
|
||||
} else {
|
||||
updatedList.push(editingPrompt);
|
||||
}
|
||||
const updatedSettings = { ...aiSettings, prompts: updatedList };
|
||||
setAiSettings(updatedSettings);
|
||||
api.updateAISettings(updatedSettings);
|
||||
setEditingPrompt(null);
|
||||
setMessage(`Saved prompt template "${editingPrompt.name}".`);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}}
|
||||
>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Template Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editingPrompt.name}
|
||||
onChange={(e) => setEditingPrompt({ ...editingPrompt, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editingPrompt.description || ''}
|
||||
onChange={(e) => setEditingPrompt({ ...editingPrompt, description: e.target.value })}
|
||||
placeholder="Brief description of what this prompt is designed for"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<label className="form-label" style={{ margin: 0 }}>Prompt Instructions</label>
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{['{{title}}', '{{content}}', '{{feed_title}}', '{{author}}'].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.65rem', padding: '1px 5px' }}
|
||||
onClick={() => setEditingPrompt({ ...editingPrompt, prompt: editingPrompt.prompt + ' ' + v })}
|
||||
>
|
||||
+{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={6}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.78rem' }}
|
||||
value={editingPrompt.prompt}
|
||||
onChange={(e) => setEditingPrompt({ ...editingPrompt, prompt: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '0.3rem' }}>
|
||||
Use variable placeholders like <code>{`{{title}}`}</code> and <code>{`{{content}}`}</code> which will be replaced dynamically at runtime.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setEditingPrompt(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
Save Template
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
||||
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Star,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Loader2,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ThreePaneViewProps {
|
||||
@@ -53,7 +54,9 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
}) => {
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
||||
const summaryCardRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||
|
||||
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
|
||||
@@ -165,9 +168,6 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||
}
|
||||
if (selectedVariantKey === 'ai_summary' && selectedEntry.variants?.['ai_summary']) {
|
||||
return `<div style="padding: 1rem; border-left: 3px solid var(--accent); background: rgba(56, 189, 248, 0.05); margin-bottom: 1.5rem; border-radius: 4px;"><strong>AI Summary:</strong><p>${selectedEntry.variants['ai_summary'].text_value}</p></div>` + (selectedEntry.content || selectedEntry.raw_content || '');
|
||||
}
|
||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||
};
|
||||
|
||||
@@ -240,11 +240,23 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
|
||||
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
||||
|
||||
{getEntryTags(entry.variants).length > 0 && (
|
||||
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{getEntryTags(entry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
{entry.variants?.['ai_summary'] && (
|
||||
<span
|
||||
className="variant-tag-chip"
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.08)',
|
||||
color: '#34d399',
|
||||
borderColor: 'rgba(16, 185, 129, 0.25)',
|
||||
}}
|
||||
>
|
||||
✨ AI Summary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -341,22 +353,34 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
{hasAISummary && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'ai_summary' ? 'active' : ''}`}
|
||||
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
border: '1px solid',
|
||||
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 9px',
|
||||
fontSize: '0.72rem',
|
||||
background: selectedVariantKey === 'ai_summary' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'ai_summary' ? '#a855f7' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'ai_summary' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
||||
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
||||
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
marginLeft: '0.4rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('ai_summary')}
|
||||
title="AI Summary"
|
||||
onClick={() => {
|
||||
const next = !showAISummary;
|
||||
setShowAISummary(next);
|
||||
if (next) {
|
||||
setTimeout(() => {
|
||||
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
||||
>
|
||||
<Sparkles size={12} style={{ marginRight: 4 }} />
|
||||
Summary
|
||||
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
||||
<span>Summary</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -463,6 +487,66 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Summary Card if present and visible */}
|
||||
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
||||
<div
|
||||
ref={summaryCardRef}
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.05)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.22)',
|
||||
borderRadius: 10,
|
||||
padding: '1rem 1.15rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
||||
<Sparkles size={15} />
|
||||
<span>AI Summary & Takeaways</span>
|
||||
{selectedEntry.variants.ai_summary.metadata?.model && (
|
||||
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Copy Summary"
|
||||
onClick={() => {
|
||||
if (selectedEntry.variants?.ai_summary?.text_value) {
|
||||
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Hide Summary"
|
||||
onClick={() => setShowAISummary(false)}
|
||||
>
|
||||
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { Category, FeedEntry, FilterState } from '../types';
|
||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
||||
import {
|
||||
Star,
|
||||
Inbox,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WaterfallViewProps {
|
||||
@@ -42,8 +43,10 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
}) => {
|
||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||
const readerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const summaryCardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset variant selection when selected article changes
|
||||
useEffect(() => {
|
||||
@@ -74,12 +77,6 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||
}
|
||||
if (selectedVariantKey === 'ai_summary' && selectedEntry.variants?.['ai_summary']) {
|
||||
return (
|
||||
`<div style="padding: 1rem; border-left: 3px solid var(--accent); background: rgba(56, 189, 248, 0.05); margin-bottom: 1.5rem; border-radius: 4px;"><strong>AI Summary:</strong><p>${selectedEntry.variants['ai_summary'].text_value}</p></div>` +
|
||||
(selectedEntry.content || selectedEntry.raw_content || '')
|
||||
);
|
||||
}
|
||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||
};
|
||||
|
||||
@@ -213,11 +210,23 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
<h3 className="xhs-card-title">{entry.title}</h3>
|
||||
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
||||
|
||||
{getEntryTags(entry.variants).length > 0 && (
|
||||
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4, marginBottom: 4 }}>
|
||||
{getEntryTags(entry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
{entry.variants?.['ai_summary'] && (
|
||||
<span
|
||||
className="variant-tag-chip"
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.08)',
|
||||
color: '#34d399',
|
||||
borderColor: 'rgba(16, 185, 129, 0.25)',
|
||||
}}
|
||||
>
|
||||
✨ AI Summary
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -338,21 +347,33 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
{hasAISummary && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'ai_summary' ? 'active' : ''}`}
|
||||
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
border: '1px solid',
|
||||
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.7rem',
|
||||
background: selectedVariantKey === 'ai_summary' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'ai_summary' ? '#a855f7' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'ai_summary' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
||||
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
||||
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('ai_summary')}
|
||||
title="AI Summary"
|
||||
onClick={() => {
|
||||
const next = !showAISummary;
|
||||
setShowAISummary(next);
|
||||
if (next) {
|
||||
setTimeout(() => {
|
||||
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
||||
>
|
||||
<Sparkles size={12} style={{ marginRight: 3 }} />
|
||||
Summary
|
||||
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
||||
<span>Summary</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -468,6 +489,66 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Summary Card if present and visible */}
|
||||
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
||||
<div
|
||||
ref={summaryCardRef}
|
||||
style={{
|
||||
background: 'rgba(16, 185, 129, 0.05)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.22)',
|
||||
borderRadius: 10,
|
||||
padding: '1rem 1.15rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
||||
<Sparkles size={15} />
|
||||
<span>AI Summary & Takeaways</span>
|
||||
{selectedEntry.variants.ai_summary.metadata?.model && (
|
||||
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Copy Summary"
|
||||
onClick={() => {
|
||||
if (selectedEntry.variants?.ai_summary?.text_value) {
|
||||
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
||||
title="Hide Summary"
|
||||
onClick={() => setShowAISummary(false)}
|
||||
>
|
||||
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dynamic Content Variant Rendering */}
|
||||
{selectedVariantKey === 'tts' && hasTTS ? (
|
||||
<div className="audio-player-container" style={{ margin: '1.5rem 0', padding: '1rem', background: 'var(--bg-card-hover)', borderRadius: '12px' }}>
|
||||
|
||||
Reference in New Issue
Block a user