Files
JARVIS/docs/superpowers/plans/2026-03-21-forum-redesign-implementation.md

42 KiB
Raw Blame History

交互广场重新设计实现计划

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 - 在当前会话中按批次执行任务

选择哪种方式?