42 KiB
交互广场重新设计实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将论坛重构为三个AI驱动的智能板块:AI学习、AI建议、AI交互
Architecture: 前端三板块布局,后端三个Service处理业务逻辑,数据库新增三张表存储学习记录、建议和交互主题
Tech Stack: Vue 3 + TypeScript + FastAPI + SQLAlchemy + LLM
文件结构
backend/app/
├── models/
│ ├── learning_record.py # 新建 - 学习记录模型
│ ├── suggestion.py # 新建 - 建议模型
│ └── interactive_topic.py # 新建 - 交互主题模型
├── schemas/
│ ├── learning.py # 新建 - LearningRecord Pydantic schemas
│ ├── suggestion.py # 新建 - Suggestion Pydantic schemas
│ └── interactive.py # 新建 - InteractiveTopic Pydantic schemas
├── services/
│ ├── learning_service.py # 新建 - AI学习服务
│ ├── suggestion_service.py # 新建 - AI建议服务
│ └── interactive_service.py # 新建 - AI交互服务
└── routers/
└── forum.py # 修改 - 添加新接口
frontend/src/
├── api/
│ └── forum.ts # 修改 - 添加新API方法
├── views/
│ └── ForumView.vue # 修改 - 重写为三板块布局
└── components/forum/
├── LearningSection.vue # 新建 - AI学习板块
│ ├── LearningSummaryCard.vue # 新建 - 今日摘要卡片
│ ├── LearningTimeline.vue # 新建 - 学习历史时间线
│ └── LearningStats.vue # 新建 - 图谱更新统计
├── SuggestionSection.vue # 新建 - AI建议板块
│ └── SuggestionCard.vue # 新建 - 建议卡片
└── InteractiveSection.vue # 新建 - AI交互板块
└── LearningInput.vue # 新建 - 学习主题输入框
Task 1: 创建 LearningRecord 模型
Files:
-
Create:
backend/app/models/learning_record.py -
Step 1: 创建 learning_record.py
from sqlalchemy import Column, String, Text, ForeignKey, JSON
from app.models.base import BaseModel
class LearningRecord(BaseModel):
__tablename__ = "learning_records"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
learning_type = Column(String(50), nullable=False) # concept, technology, workflow
topic = Column(String(500), nullable=False) # 学习主题
summary = Column(Text, nullable=False) # AI生成的学习摘要
source = Column(String(50), nullable=False) # conversation, kanban, knowledge
source_ids = Column(JSON, nullable=True) # 来源ID列表
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
- Step 2: 在 models/init.py 中导出
from app.models.learning_record import LearningRecord
Task 2: 创建 Suggestion 模型
Files:
-
Create:
backend/app/models/suggestion.py -
Step 1: 创建 suggestion.py
from sqlalchemy import Column, String, Text, ForeignKey, JSON, Boolean
from app.models.base import BaseModel
class Suggestion(BaseModel):
__tablename__ = "suggestions"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
suggestion_type = Column(String(50), nullable=False) # knowledge, efficiency, skill
title = Column(String(500), nullable=False) # 建议标题
content = Column(Text, nullable=False) # 建议内容
source_data = Column(JSON, nullable=True) # 分析依据
is_read = Column(Boolean, default=False) # 是否已读
is_dismissed = Column(Boolean, default=False) # 是否忽略
- Step 2: 在 models/init.py 中导出
from app.models.suggestion import Suggestion
Task 3: 创建 InteractiveTopic 模型
Files:
-
Create:
backend/app/models/interactive_topic.py -
Step 1: 创建 interactive_topic.py
from sqlalchemy import Column, String, Text, ForeignKey, JSON, DateTime
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class InteractiveTopic(BaseModel):
__tablename__ = "interactive_topics"
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
topic = Column(String(500), nullable=False) # 学习主题
status = Column(String(50), nullable=False) # pending, learning, completed, failed
result = Column(Text, nullable=True) # 学习结果/报告
kg_nodes_created = Column(JSON, nullable=True) # 创建的KGNode ID列表
source = Column(String(50), nullable=False) # user_initiated, ai_proactive
completed_at = Column(DateTime, nullable=True)
- Step 2: 在 models/init.py 中导出
from app.models.interactive_topic import InteractiveTopic
Task 4: 创建 Pydantic Schemas
Files:
-
Create:
backend/app/schemas/learning.py -
Create:
backend/app/schemas/suggestion.py -
Create:
backend/app/schemas/interactive.py -
Step 1: 创建 learning.py
from pydantic import BaseModel
from typing import Optional
class LearningRecordOut(BaseModel):
id: str
learning_type: str
topic: str
summary: str
source: str
source_ids: Optional[dict] = None
kg_nodes_created: Optional[list[str]] = None
created_at: str
model_config = {"from_attributes": True}
class LearningSummaryOut(BaseModel):
summary: str
records: list[LearningRecordOut]
stats: dict
class LearningHistoryOut(BaseModel):
records: list[LearningRecordOut]
total: int
- Step 2: 创建 suggestion.py
from pydantic import BaseModel
from typing import Optional
class SuggestionOut(BaseModel):
id: str
suggestion_type: str
title: str
content: str
source_data: Optional[dict] = None
is_read: bool
is_dismissed: bool
created_at: str
model_config = {"from_attributes": True}
class SuggestionListOut(BaseModel):
suggestions: list[SuggestionOut]
- Step 3: 创建 interactive.py
from pydantic import BaseModel
from typing import Optional
class InteractiveTopicOut(BaseModel):
id: str
topic: str
status: str
result: Optional[str] = None
kg_nodes_created: Optional[list[str]] = None
source: str
created_at: str
completed_at: Optional[str] = None
model_config = {"from_attributes": True}
class InteractiveTopicsOut(BaseModel):
user_initiated: list[InteractiveTopicOut]
ai_proactive: list[InteractiveTopicOut]
class LearnRequest(BaseModel):
topic: str
class LearnResponse(BaseModel):
topic_id: str
status: str
Task 5: 创建 LearningService
Files:
-
Create:
backend/app/services/learning_service.py -
Step 1: 创建 learning_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.models.learning_record import LearningRecord
from app.models.conversation import Message
from app.models.task import Task
from app.models.knowledge_graph import KGNode
from app.core.llm import get_llm_client
from datetime import datetime, timedelta
from typing import Optional
class LearningService:
def __init__(self, db: AsyncSession):
self.db = db
self.llm = get_llm_client()
async def get_summary(self, user_id: str) -> dict:
"""获取今日学习摘要"""
today = datetime.utcnow().date()
today_start = datetime.combine(today, datetime.min.time())
result = await self.db.execute(
select(LearningRecord)
.where(
LearningRecord.user_id == user_id,
LearningRecord.created_at >= today_start
)
.order_by(desc(LearningRecord.created_at))
)
records = result.scalars().all()
if not records:
return {
"summary": "今日暂无学习记录",
"records": [],
"stats": {"nodes_created": 0, "edges_created": 0}
}
# 生成摘要
topics = [r.topic for r in records]
summary = f"今日学习了 {len(topics)} 个主题:{', '.join(topics[:3])}"
# 统计
nodes_count = sum(len(r.kg_nodes_created or []) for r in records)
return {
"summary": summary,
"records": [self._record_to_dict(r) for r in records],
"stats": {"nodes_created": nodes_count, "edges_created": 0}
}
async def get_history(self, user_id: str, page: int = 1, limit: int = 20) -> dict:
"""获取学习历史"""
offset = (page - 1) * limit
result = await self.db.execute(
select(LearningRecord)
.where(LearningRecord.user_id == user_id)
.order_by(desc(LearningRecord.created_at))
.limit(limit)
.offset(offset)
)
records = result.scalars().all()
count_result = await self.db.execute(
select(LearningRecord)
.where(LearningRecord.user_id == user_id)
)
total = len(count_result.scalars().all())
return {
"records": [self._record_to_dict(r) for r in records],
"total": total
}
def _record_to_dict(self, record: LearningRecord) -> dict:
return {
"id": record.id,
"learning_type": record.learning_type,
"topic": record.topic,
"summary": record.summary,
"source": record.source,
"source_ids": record.source_ids,
"kg_nodes_created": record.kg_nodes_created,
"created_at": record.created_at.isoformat() if record.created_at else None
}
Task 6: 创建 SuggestionService
Files:
-
Create:
backend/app/services/suggestion_service.py -
Step 1: 创建 suggestion_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.suggestion import Suggestion
from app.models.knowledge_graph import KGNode
from app.core.llm import get_llm_client
from datetime import datetime
class SuggestionService:
def __init__(self, db: AsyncSession):
self.db = db
self.llm = get_llm_client()
async def get_suggestions(self, user_id: str) -> list[dict]:
"""获取用户的所有建议"""
result = await self.db.execute(
select(Suggestion)
.where(
Suggestion.user_id == user_id,
Suggestion.is_dismissed == False
)
.order_by(Suggestion.created_at.desc())
)
suggestions = result.scalars().all()
return [self._suggestion_to_dict(s) for s in suggestions]
async def get_suggestion(self, suggestion_id: str, user_id: str) -> dict:
"""获取单个建议"""
result = await self.db.execute(
select(Suggestion).where(
Suggestion.id == suggestion_id,
Suggestion.user_id == user_id
)
)
suggestion = result.scalar_one_or_none()
if not suggestion:
return None
return self._suggestion_to_dict(suggestion)
async def mark_read(self, suggestion_id: str, user_id: str) -> bool:
"""标记建议为已读"""
result = await self.db.execute(
select(Suggestion).where(
Suggestion.id == suggestion_id,
Suggestion.user_id == user_id
)
)
suggestion = result.scalar_one_or_none()
if not suggestion:
return False
suggestion.is_read = True
await self.db.commit()
return True
async def dismiss(self, suggestion_id: str, user_id: str) -> bool:
"""忽略建议"""
result = await self.db.execute(
select(Suggestion).where(
Suggestion.id == suggestion_id,
Suggestion.user_id == user_id
)
)
suggestion = result.scalar_one_or_none()
if not suggestion:
return False
suggestion.is_dismissed = True
await self.db.commit()
return True
def _suggestion_to_dict(self, suggestion: Suggestion) -> dict:
return {
"id": suggestion.id,
"suggestion_type": suggestion.suggestion_type,
"title": suggestion.title,
"content": suggestion.content,
"source_data": suggestion.source_data,
"is_read": suggestion.is_read,
"is_dismissed": suggestion.is_dismissed,
"created_at": suggestion.created_at.isoformat() if suggestion.created_at else None
}
Task 7: 创建 InteractiveService
Files:
-
Create:
backend/app/services/interactive_service.py -
Step 1: 创建 interactive_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.models.interactive_topic import InteractiveTopic
from app.core.llm import get_llm_client
from datetime import datetime
class InteractiveService:
def __init__(self, db: AsyncSession):
self.db = db
self.llm = get_llm_client()
async def get_topics(self, user_id: str) -> dict:
"""获取交互主题列表"""
result = await self.db.execute(
select(InteractiveTopic)
.where(InteractiveTopic.user_id == user_id)
.order_by(desc(InteractiveTopic.created_at))
)
topics = result.scalars().all()
user_initiated = [t for t in topics if t.source == "user_initiated"]
ai_proactive = [t for t in topics if t.source == "ai_proactive"]
return {
"user_initiated": [self._topic_to_dict(t) for t in user_initiated],
"ai_proactive": [self._topic_to_dict(t) for t in ai_proactive]
}
async def initiate_learning(self, user_id: str, topic: str) -> dict:
"""用户发起学习"""
interactive_topic = InteractiveTopic(
user_id=user_id,
topic=topic,
status="pending",
source="user_initiated"
)
self.db.add(interactive_topic)
await self.db.commit()
await self.db.refresh(interactive_topic)
# 触发异步学习(实际实现中可能用后台任务)
# 这里简化为直接返回
return self._topic_to_dict(interactive_topic)
async def get_topic_detail(self, topic_id: str, user_id: str) -> dict:
"""获取主题详情"""
result = await self.db.execute(
select(InteractiveTopic).where(
InteractiveTopic.id == topic_id,
InteractiveTopic.user_id == user_id
)
)
topic = result.scalar_one_or_none()
if not topic:
return None
return self._topic_to_dict(topic)
def _topic_to_dict(self, topic: InteractiveTopic) -> dict:
return {
"id": topic.id,
"topic": topic.topic,
"status": topic.status,
"result": topic.result,
"kg_nodes_created": topic.kg_nodes_created,
"source": topic.source,
"created_at": topic.created_at.isoformat() if topic.created_at else None,
"completed_at": topic.completed_at.isoformat() if topic.completed_at else None
}
Task 8: 修改 Forum Router
Files:
-
Modify:
backend/app/routers/forum.py -
Step 1: 添加新接口
在文件开头添加导入:
from app.schemas.learning import LearningSummaryOut, LearningHistoryOut
from app.schemas.suggestion import SuggestionOut, SuggestionListOut
from app.schemas.interactive import InteractiveTopicOut, InteractiveTopicsOut, LearnRequest, LearnResponse
from app.services.learning_service import LearningService
from app.services.suggestion_service import SuggestionService
from app.services.interactive_service import InteractiveService
- Step 2: 添加 Learning 接口
@router.get("/learning/summary", response_model=LearningSummaryOut)
async def get_learning_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = LearningService(db)
return await service.get_summary(current_user.id)
@router.get("/learning/history", response_model=LearningHistoryOut)
async def get_learning_history(
page: int = 1,
limit: int = 20,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = LearningService(db)
return await service.get_history(current_user.id, page, limit)
- Step 3: 添加 Suggestion 接口
@router.get("/suggestions", response_model= dict)
async def list_suggestions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = SuggestionService(db)
suggestions = await service.get_suggestions(current_user.id)
return {"suggestions": suggestions}
@router.get("/suggestions/{suggestion_id}", response_model=SuggestionOut)
async def get_suggestion(
suggestion_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = SuggestionService(db)
suggestion = await service.get_suggestion(suggestion_id, current_user.id)
if not suggestion:
raise HTTPException(status_code=404, detail="建议不存在")
return suggestion
@router.patch("/suggestions/{suggestion_id}/read")
async def mark_suggestion_read(
suggestion_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = SuggestionService(db)
success = await service.mark_read(suggestion_id, current_user.id)
if not success:
raise HTTPException(status_code=404, detail="建议不存在")
return {"status": "ok"}
@router.delete("/suggestions/{suggestion_id}/dismiss")
async def dismiss_suggestion(
suggestion_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = SuggestionService(db)
success = await service.dismiss(suggestion_id, current_user.id)
if not success:
raise HTTPException(status_code=404, detail="建议不存在")
return {"status": "ok"}
- Step 4: 添加 Interactive 接口
@router.get("/interactive/topics", response_model=InteractiveTopicsOut)
async def get_interactive_topics(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = InteractiveService(db)
return await service.get_topics(current_user.id)
@router.post("/interactive/learn", response_model=InteractiveTopicOut)
async def initiate_learning(
data: LearnRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = InteractiveService(db)
result = await service.initiate_learning(current_user.id, data.topic)
return result
@router.get("/interactive/topics/{topic_id}", response_model=InteractiveTopicOut)
async def get_interactive_topic(
topic_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
service = InteractiveService(db)
topic = await service.get_topic_detail(topic_id, current_user.id)
if not topic:
raise HTTPException(status_code=404, detail="主题不存在")
return topic
Task 9: 更新前端 API
Files:
-
Modify:
frontend/src/api/forum.ts -
Step 1: 添加新类型和API方法
// Types
export interface LearningRecord {
id: string
learning_type: 'concept' | 'technology' | 'workflow'
topic: string
summary: string
source: string
source_ids?: { conversation_ids?: string[]; task_ids?: string[] }
kg_nodes_created?: string[]
created_at: string
}
export interface LearningSummary {
summary: string
records: LearningRecord[]
stats: {
nodes_created: number
edges_created: number
}
}
export interface Suggestion {
id: string
suggestion_type: 'knowledge' | 'efficiency' | 'skill'
title: string
content: string
source_data?: Record<string, any>
is_read: boolean
is_dismissed: boolean
created_at: string
}
export interface InteractiveTopic {
id: string
topic: string
status: 'pending' | 'learning' | 'completed' | 'failed'
result?: string
kg_nodes_created?: string[]
source: 'user_initiated' | 'ai_proactive'
created_at: string
completed_at?: string
}
// API methods
export const forumApi = {
// ... existing methods ...
// Learning
fetchLearningSummary() {
return api.get<LearningSummary>('/api/forum/learning/summary')
},
fetchLearningHistory(params: { page: number, limit: number }) {
return api.get<{ records: LearningRecord[], total: number }>('/api/forum/learning/history', { params })
},
// Suggestions
fetchSuggestions() {
return api.get<{ suggestions: Suggestion[] }>('/api/forum/suggestions')
},
getSuggestion(id: string) {
return api.get<Suggestion>(`/api/forum/suggestions/${id}`)
},
markSuggestionRead(id: string) {
return api.patch(`/api/forum/suggestions/${id}/read`)
},
dismissSuggestion(id: string) {
return api.delete(`/api/forum/suggestions/${id}/dismiss`)
},
// Interactive
fetchInteractiveTopics() {
return api.get<{ user_initiated: InteractiveTopic[], ai_proactive: InteractiveTopic[] }>('/api/forum/interactive/topics')
},
initiateLearning(topic: string) {
return api.post<InteractiveTopic>('/api/forum/interactive/learn', { topic })
},
getTopicDetail(id: string) {
return api.get<InteractiveTopic>(`/api/forum/interactive/topics/${id}`)
},
}
Task 10: 创建 LearningSection 组件
Files:
-
Create:
frontend/src/components/forum/LearningSection.vue -
Create:
frontend/src/components/forum/LearningSummaryCard.vue -
Create:
frontend/src/components/forum/LearningTimeline.vue -
Create:
frontend/src/components/forum/LearningStats.vue -
Step 1: 创建 LearningSummaryCard.vue
<script setup lang="ts">
import { Brain, Clock } from 'lucide-vue-next'
defineProps<{
summary: string
date: string
}>()
</script>
<template>
<div class="summary-card">
<div class="card-header">
<Brain :size="18" class="icon" />
<span class="label">TODAY'S LEARNING</span>
<Clock :size="12" class="clock" />
</div>
<div class="card-body">
<p class="summary-text">{{ summary }}</p>
</div>
</div>
</template>
<style scoped>
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 16px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.icon { color: var(--accent-purple); }
.label {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--accent-purple);
}
.clock { color: var(--text-dim); margin-left: auto; }
.summary-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
</style>
- Step 2: 创建 LearningTimeline.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { forumApi, type LearningRecord } from '@/api/forum'
import { Timeline } from 'lucide-vue-next'
const records = ref<LearningRecord[]>([])
const loading = ref(true)
async function loadHistory() {
try {
const res = await forumApi.fetchLearningHistory({ page: 1, limit: 10 })
records.value = res.data.records
} catch (e) {
console.error('Failed to load history:', e)
} finally {
loading.value = false
}
}
function formatDate(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function getTypeIcon(type: string) {
return type === 'concept' ? '💡' : type === 'technology' ? '⚙️' : '📋'
}
onMounted(() => loadHistory())
</script>
<template>
<div class="timeline-section">
<div class="section-header">
<Timeline :size="16" />
<span>LEARNING HISTORY</span>
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="records.length === 0" class="empty">No learning records yet</div>
<div v-else class="timeline">
<div v-for="record in records" :key="record.id" class="timeline-item">
<div class="timeline-dot">{{ getTypeIcon(record.learning_type) }}</div>
<div class="timeline-content">
<div class="timeline-topic">{{ record.topic }}</div>
<div class="timeline-summary">{{ record.summary }}</div>
<div class="timeline-date">{{ formatDate(record.created_at) }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.timeline-section {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 16px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--accent-purple);
margin-bottom: 16px;
}
.timeline { display: flex; flex-direction: column; gap: 12px; }
.timeline-item { display: flex; gap: 12px; }
.timeline-dot { font-size: 16px; flex-shrink: 0; }
.timeline-content { flex: 1; min-width: 0; }
.timeline-topic { font-size: 13px; color: var(--text-primary); font-weight: 500; }
.timeline-summary { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
.timeline-date { font-size: 10px; color: var(--text-dim); margin-top: 4px; }
.loading, .empty { font-size: 12px; color: var(--text-dim); padding: 20px; text-align: center; }
</style>
- Step 3: 创建 LearningStats.vue
<script setup lang="ts">
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
defineProps<{
nodesCreated: number
edgesCreated: number
}>()
</script>
<template>
<div class="stats-card">
<div class="stat-item">
<span class="stat-value">{{ nodesCreated }}</span>
<span class="stat-label">NODES CREATED</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ edgesCreated }}</span>
<span class="stat-label">EDGES CREATED</span>
</div>
</div>
</template>
<style scoped>
.stats-card {
display: flex;
gap: 24px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 16px;
}
.stat-item { display: flex; flex-direction: column; gap: 4px; }
.stat-value {
font-family: var(--font-mono);
font-size: 24px;
font-weight: 700;
color: var(--accent-cyan);
}
.stat-label {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
</style>
- Step 4: 创建 LearningSection.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { forumApi, type LearningSummary } from '@/api/forum'
import LearningSummaryCard from './LearningSummaryCard.vue'
import LearningTimeline from './LearningTimeline.vue'
import LearningStats from './LearningStats.vue'
const summary = ref<LearningSummary | null>(null)
const loading = ref(true)
async function loadSummary() {
try {
const res = await forumApi.fetchLearningSummary()
summary.value = res.data
} catch (e) {
console.error('Failed to load summary:', e)
} finally {
loading.value = false
}
}
onMounted(() => loadSummary())
</script>
<template>
<section class="learning-section">
<div class="section-header">
<span class="section-tag">MODEL LEARNING</span>
<h2>AI学习板块</h2>
<p class="section-desc">AI分析你的活动,学习知识并汇报</p>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="summary">
<LearningSummaryCard :summary="summary.summary" :date="new Date().toISOString()" />
<LearningStats :nodes-created="summary.stats.nodes_created" :edges-created="summary.stats.edges_created" />
<LearningTimeline />
</template>
</section>
</template>
<style scoped>
.learning-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header { margin-bottom: 8px; }
.section-tag {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--accent-purple);
background: var(--accent-purple-dim);
padding: 2px 8px;
border-radius: 4px;
}
h2 {
font-family: var(--font-display);
font-size: 18px;
color: var(--text-primary);
margin: 8px 0 4px;
}
.section-desc { font-size: 12px; color: var(--text-dim); }
.loading { color: var(--text-dim); padding: 20px; text-align: center; }
</style>
Task 11: 创建 SuggestionSection 组件
Files:
-
Create:
frontend/src/components/forum/SuggestionSection.vue -
Create:
frontend/src/components/forum/SuggestionCard.vue -
Step 1: 创建 SuggestionCard.vue
<script setup lang="ts">
import { Lightbulb, TrendingUp, Clock, X } from 'lucide-vue-next'
import type { Suggestion } from '@/api/forum'
const props = defineProps<{
suggestion: Suggestion
}>()
const emit = defineEmits<{
dismiss: [id: string]
read: [id: string]
}>()
const icons = {
knowledge: Lightbulb,
efficiency: TrendingUp,
skill: Clock
}
const colors = {
knowledge: 'var(--accent-cyan)',
efficiency: 'var(--accent-green)',
skill: 'var(--accent-amber)'
}
</script>
<template>
<div class="suggestion-card" :class="{ read: suggestion.is_read }">
<div class="card-icon" :style="{ color: colors[suggestion.suggestion_type] }">
<component :is="icons[suggestion.suggestion_type]" :size="20" />
</div>
<div class="card-content">
<div class="card-type">{{ suggestion.suggestion_type.toUpperCase() }}</div>
<div class="card-title">{{ suggestion.title }}</div>
<div class="card-body">{{ suggestion.content }}</div>
</div>
<button class="dismiss-btn" @click="emit('dismiss', suggestion.id)" title="忽略">
<X :size="14" />
</button>
</div>
</template>
<style scoped>
.suggestion-card {
display: flex;
gap: 12px;
padding: 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
transition: all var(--transition-fast);
}
.suggestion-card:hover { border-color: var(--border-mid); }
.suggestion-card.read { opacity: 0.6; }
.card-icon { flex-shrink: 0; }
.card-content { flex: 1; min-width: 0; }
.card-type {
font-family: var(--font-display);
font-size: 8px;
letter-spacing: 0.1em;
color: var(--text-dim);
margin-bottom: 4px;
}
.card-title {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
margin-bottom: 4px;
}
.card-body { font-size: 11px; color: var(--text-secondary); line-height: 1.5; }
.dismiss-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all var(--transition-fast);
}
.dismiss-btn:hover { color: var(--accent-red); background: rgba(255, 71, 87, 0.1); }
</style>
- Step 2: 创建 SuggestionSection.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { forumApi, type Suggestion } from '@/api/forum'
import SuggestionCard from './SuggestionCard.vue'
import { MessageCircle } from 'lucide-vue-next'
const suggestions = ref<Suggestion[]>([])
const loading = ref(true)
async function loadSuggestions() {
try {
const res = await forumApi.fetchSuggestions()
suggestions.value = res.data.suggestions
} catch (e) {
console.error('Failed to load suggestions:', e)
} finally {
loading.value = false
}
}
async function handleDismiss(id: string) {
await forumApi.dismissSuggestion(id)
suggestions.value = suggestions.value.filter(s => s.id !== id)
}
onMounted(() => loadSuggestions())
</script>
<template>
<section class="suggestion-section">
<div class="section-header">
<span class="section-tag">SUGGESTIONS</span>
<h2>AI建议板块</h2>
<p class="section-desc">基于你的习惯提供个性化建议</p>
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="suggestions.length === 0" class="empty">
<MessageCircle :size="32" />
<span>No suggestions yet</span>
</div>
<div v-else class="suggestion-list">
<SuggestionCard
v-for="s in suggestions"
:key="s.id"
:suggestion="s"
@dismiss="handleDismiss"
/>
</div>
</section>
</template>
<style scoped>
.suggestion-section { display: flex; flex-direction: column; gap: 16px; }
.section-header { margin-bottom: 8px; }
.section-tag {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--accent-amber);
background: rgba(251, 191, 36, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
h2 {
font-family: var(--font-display);
font-size: 18px;
color: var(--text-primary);
margin: 8px 0 4px;
}
.section-desc { font-size: 12px; color: var(--text-dim); }
.suggestion-list { display: flex; flex-direction: column; gap: 12px; }
.loading, .empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px;
color: var(--text-dim);
font-size: 12px;
}
</style>
Task 12: 创建 InteractiveSection 组件
Files:
-
Create:
frontend/src/components/forum/InteractiveSection.vue -
Create:
frontend/src/components/forum/LearningInput.vue -
Step 1: 创建 LearningInput.vue
<script setup lang="ts">
import { ref } from 'vue'
import { Send, Sparkles } from 'lucide-vue-next'
const emit = defineEmits<{
submit: [topic: string]
}>()
const topic = ref('')
const loading = ref(false)
async function handleSubmit() {
if (!topic.value.trim() || loading.value) return
loading.value = true
emit('submit', topic.value.trim())
topic.value = ''
loading.value = false
}
</script>
<template>
<div class="learning-input">
<div class="input-wrapper">
<Sparkles :size="16" class="sparkle-icon" />
<input
v-model="topic"
type="text"
placeholder="让AI学习 [主题]..."
class="topic-input"
@keyup.enter="handleSubmit"
/>
<button class="submit-btn" @click="handleSubmit" :disabled="!topic.trim() || loading">
<Send :size="14" />
</button>
</div>
</div>
</template>
<style scoped>
.learning-input { margin-bottom: 16px; }
.input-wrapper {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
transition: all var(--transition-fast);
}
.input-wrapper:focus-within {
border-color: var(--accent-purple);
box-shadow: 0 0 0 2px var(--accent-purple-dim);
}
.sparkle-icon { color: var(--accent-purple); flex-shrink: 0; }
.topic-input {
flex: 1;
background: none;
border: none;
font-size: 13px;
color: var(--text-primary);
outline: none;
}
.topic-input::placeholder { color: var(--text-dim); }
.submit-btn {
background: var(--accent-purple-dim);
border: 1px solid rgba(123, 44, 191, 0.3);
border-radius: var(--radius-md);
color: var(--accent-purple);
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
transition: all var(--transition-fast);
}
.submit-btn:hover:not(:disabled) {
background: rgba(123, 44, 191, 0.2);
}
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
- Step 2: 创建 InteractiveSection.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { forumApi, type InteractiveTopic } from '@/api/forum'
import LearningInput from './LearningInput.vue'
import { User, Bot, RefreshCw, CheckCircle } from 'lucide-vue-next'
const userTopics = ref<InteractiveTopic[]>([])
const aiTopics = ref<InteractiveTopic[]>([])
const loading = ref(true)
async function loadTopics() {
try {
const res = await forumApi.fetchInteractiveTopics()
userTopics.value = res.data.user_initiated
aiTopics.value = res.data.ai_proactive
} catch (e) {
console.error('Failed to load topics:', e)
} finally {
loading.value = false
}
}
async function handleInitiateLearn(topic: string) {
try {
await forumApi.initiateLearning(topic)
await loadTopics()
} catch (e) {
console.error('Failed to initiate learning:', e)
}
}
function getStatusIcon(status: string) {
switch (status) {
case 'completed': return CheckCircle
case 'learning': return RefreshCw
default: return RefreshCw
}
}
function getStatusColor(status: string) {
switch (status) {
case 'completed': return 'var(--accent-green)'
case 'failed': return 'var(--accent-red)'
default: return 'var(--text-dim)'
}
}
onMounted(() => loadTopics())
</script>
<template>
<section class="interactive-section">
<div class="section-header">
<span class="section-tag">INTERACTIVE</span>
<h2>AI交互板块</h2>
<p class="section-desc">用户发起学习主题,AI主动探索</p>
</div>
<LearningInput @submit="handleInitiateLearn" />
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- User Initiated -->
<div class="subsection">
<div class="subsection-header">
<User :size="14" />
<span>USER INITIATED</span>
</div>
<div v-if="userTopics.length === 0" class="empty-sub">No user-initiated topics</div>
<div v-else class="topic-list">
<div v-for="topic in userTopics" :key="topic.id" class="topic-item">
<component :is="getStatusIcon(topic.status)" :size="14" :style="{ color: getStatusColor(topic.status) }" />
<span class="topic-name">{{ topic.topic }}</span>
<span class="topic-status">{{ topic.status }}</span>
</div>
</div>
</div>
<!-- AI Proactive -->
<div class="subsection">
<div class="subsection-header">
<Bot :size="14" />
<span>AI PROACTIVE</span>
</div>
<div v-if="aiTopics.length === 0" class="empty-sub">No AI proactive topics</div>
<div v-else class="topic-list">
<div v-for="topic in aiTopics" :key="topic.id" class="topic-item">
<component :is="getStatusIcon(topic.status)" :size="14" :style="{ color: getStatusColor(topic.status) }" />
<span class="topic-name">{{ topic.topic }}</span>
<span class="topic-status">{{ topic.status }}</span>
</div>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.interactive-section { display: flex; flex-direction: column; gap: 16px; }
.section-header { margin-bottom: 8px; }
.section-tag {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
background: var(--accent-cyan-dim);
padding: 2px 8px;
border-radius: 4px;
}
h2 {
font-family: var(--font-display);
font-size: 18px;
color: var(--text-primary);
margin: 8px 0 4px;
}
.section-desc { font-size: 12px; color: var(--text-dim); }
.subsection {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 16px;
}
.subsection-header {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 12px;
}
.topic-list { display: flex; flex-direction: column; gap: 8px; }
.topic-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--bg-panel);
border-radius: var(--radius-md);
}
.topic-name { flex: 1; font-size: 12px; color: var(--text-primary); }
.topic-status {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
}
.empty-sub { font-size: 11px; color: var(--text-dim); }
.loading { color: var(--text-dim); padding: 20px; text-align: center; }
</style>
Task 13: 重写 ForumView.vue
Files:
-
Modify:
frontend/src/views/ForumView.vue -
Step 1: 重写 ForumView.vue
<script setup lang="ts">
import LearningSection from '@/components/forum/LearningSection.vue'
import SuggestionSection from '@/components/forum/SuggestionSection.vue'
import InteractiveSection from '@/components/forum/InteractiveSection.vue'
import { Radio } from 'lucide-vue-next'
</script>
<template>
<div class="forum-view">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<div class="header-icon"><Radio :size="20" /></div>
<div class="header-text">
<h1>INTERACTIVE PLAZA</h1>
<span class="header-sub">AI-driven learning & suggestions</span>
</div>
</div>
</div>
<!-- Three Sections -->
<div class="sections-container">
<LearningSection />
<SuggestionSection />
<InteractiveSection />
</div>
</div>
</template>
<style scoped>
.forum-view {
height: 100%;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 32px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left { display: flex; align-items: center; gap: 14px; }
.header-icon { color: var(--accent-purple); filter: drop-shadow(0 0 8px var(--accent-purple)); }
h1 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
margin: 0;
}
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
.sections-container {
display: flex;
flex-direction: column;
gap: 24px;
}
</style>
Task 14: 验证和测试
- Step 1: 后端语法检查
cd backend && python -m py_compile app/models/learning_record.py app/models/suggestion.py app/models/interactive_topic.py app/services/learning_service.py app/services/suggestion_service.py app/services/interactive_service.py app/routers/forum.py
- Step 2: 前端 TypeScript 检查
cd frontend && npx vue-tsc --noEmit
- Step 3: 启动服务测试
# 后端
cd backend && python -m uvicorn app.main:app --reload
# 前端
cd frontend && npm run dev
执行选项
1. Subagent-Driven (推荐) - 我为每个任务派遣独立的子代理,任务间进行审查,快速迭代
2. Inline Execution - 在当前会话中按批次执行任务
选择哪种方式?