diff --git a/backend/open_webui/migrations/versions/1c2d3e4f5a6b_add_user_feedback_table.py b/backend/open_webui/migrations/versions/1c2d3e4f5a6b_add_user_feedback_table.py new file mode 100644 index 0000000000..338c492936 --- /dev/null +++ b/backend/open_webui/migrations/versions/1c2d3e4f5a6b_add_user_feedback_table.py @@ -0,0 +1,42 @@ +"""Add user_feedback table for user suggestions + +Revision ID: 1c2d3e4f5a6b +Revises: f8c9d0e4a3b2 +Create Date: 2025-12-07 12:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "1c2d3e4f5a6b" +down_revision = "f8c9d0e4a3b2" +branch_labels = None +depends_on = None + + +def upgrade(): + # 新增 user_feedback 表:用于用户主动提交建议/反馈 + # 字段说明: + # - id: 主键 + # - user_id: 提交用户 + # - content: 反馈正文 + # - contact: 联系方式(可选) + # - status: 处理状态(pending/resolved) + # - created_at/updated_at: 秒级时间戳 + op.create_table( + "user_feedback", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("user_id", sa.Text(), nullable=False, index=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("contact", sa.Text(), nullable=True), + sa.Column("status", sa.Text(), nullable=False, server_default="pending"), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + ) + # SQLite does not support server_default on existing rows; ensure default is set for new rows only. + + +def downgrade(): + # 回滚时直接删除表 + op.drop_table("user_feedback") diff --git a/backend/open_webui/migrations/versions/2b3c4d5e6f7g_merge_heads_user_feedback.py b/backend/open_webui/migrations/versions/2b3c4d5e6f7g_merge_heads_user_feedback.py new file mode 100644 index 0000000000..a2938bb07d --- /dev/null +++ b/backend/open_webui/migrations/versions/2b3c4d5e6f7g_merge_heads_user_feedback.py @@ -0,0 +1,26 @@ +"""Merge heads for user_feedback branch and legacy head + +Revision ID: 2b3c4d5e6f7g +Revises: 1c2d3e4f5a6b, b2c3d4e5f6a7 +Create Date: 2025-12-07 12:10:00.000000 + +此迁移用于合并分叉的两个 head(user_feedback 分支与 b2c3d4e5f6a7),不做实际数据变更。 +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2b3c4d5e6f7g" +down_revision = ("1c2d3e4f5a6b", "b2c3d4e5f6a7") +branch_labels = None +depends_on = None + + +def upgrade(): + # 纯合并迁移,无实际操作 + pass + + +def downgrade(): + # 回滚时仅拆分分叉 + pass diff --git a/backend/open_webui/models/user_feedback.py b/backend/open_webui/models/user_feedback.py new file mode 100644 index 0000000000..0b4f6a5677 --- /dev/null +++ b/backend/open_webui/models/user_feedback.py @@ -0,0 +1,130 @@ +import time +import uuid +from typing import Optional, List + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Column, Text, BigInteger + +from open_webui.internal.db import Base, get_db +from open_webui.env import SRC_LOG_LEVELS +import logging + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + + +class UserFeedback(Base): + __tablename__ = "user_feedback" + + # 用户主动反馈建议,带 4 小时限流。仅存最基本的信息,方便前端直接插入查看。 + id = Column(Text, primary_key=True) + user_id = Column(Text, index=True, nullable=False) + content = Column(Text, nullable=False) # 反馈正文 + contact = Column(Text, nullable=True) # 可选联系方式 + status = Column(Text, default="pending") # pending/resolved + created_at = Column(BigInteger, nullable=False) # 秒级时间戳 + updated_at = Column(BigInteger, nullable=False) + + +class UserFeedbackModel(BaseModel): + id: str + user_id: str + content: str + contact: Optional[str] = None + status: str + created_at: int + updated_at: int + + model_config = ConfigDict(from_attributes=True) + + +class UserFeedbackForm(BaseModel): + content: str + contact: Optional[str] = None + + +class UserFeedbacksTable: + def create( + self, user_id: str, content: str, contact: Optional[str] = None + ) -> Optional[UserFeedbackModel]: + """创建用户反馈,默认状态 pending。""" + now_ts = int(time.time()) + record = UserFeedbackModel( + id=str(uuid.uuid4()), + user_id=user_id, + content=content, + contact=contact, + status="pending", + created_at=now_ts, + updated_at=now_ts, + ) + try: + with get_db() as db: + db_item = UserFeedback(**record.model_dump()) + db.add(db_item) + db.commit() + db.refresh(db_item) + return UserFeedbackModel.model_validate(db_item) + except Exception as e: + log.exception(f"Error creating user feedback: {e}") + return None + + def get_recent_within(self, user_id: str, seconds: int) -> Optional[UserFeedbackModel]: + """查询指定时间窗口内最新一条反馈,用于冷却期检查。""" + try: + with get_db() as db: + cutoff = int(time.time()) - seconds + item = ( + db.query(UserFeedback) + .filter(UserFeedback.user_id == user_id, UserFeedback.created_at >= cutoff) + .order_by(UserFeedback.created_at.desc()) + .first() + ) + if not item: + return None + return UserFeedbackModel.model_validate(item) + except Exception as e: + log.exception(f"Error reading recent user feedback: {e}") + return None + + def list_by_user(self, user_id: str) -> List[UserFeedbackModel]: + """按用户列出反馈,按时间倒序。""" + with get_db() as db: + items = ( + db.query(UserFeedback) + .filter(UserFeedback.user_id == user_id) + .order_by(UserFeedback.created_at.desc()) + .all() + ) + return [UserFeedbackModel.model_validate(x) for x in items] + + def list_all(self) -> List[UserFeedbackModel]: + """管理员查看全部反馈。""" + with get_db() as db: + items = db.query(UserFeedback).order_by(UserFeedback.created_at.desc()).all() + return [UserFeedbackModel.model_validate(x) for x in items] + + def update_status(self, id: str, status: str) -> Optional[UserFeedbackModel]: + """更新状态(例如管理员处理后标记 resolved)。""" + with get_db() as db: + item = db.query(UserFeedback).filter_by(id=id).first() + if not item: + return None + item.status = status + item.updated_at = int(time.time()) + db.commit() + db.refresh(item) + return UserFeedbackModel.model_validate(item) + + def delete_by_id(self, id: str) -> bool: + """删除单条反馈。""" + with get_db() as db: + item = db.query(UserFeedback).filter_by(id=id).first() + if not item: + return False + db.delete(item) + db.commit() + return True + + +UserFeedbacks = UserFeedbacksTable() diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index c76a1f6915..031a0fbbc2 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -9,6 +9,11 @@ from open_webui.models.feedbacks import ( FeedbackForm, Feedbacks, ) +from open_webui.models.user_feedback import ( + UserFeedbackModel, + UserFeedbackForm, + UserFeedbacks, +) from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_admin_user, get_verified_user @@ -71,6 +76,11 @@ class FeedbackUserResponse(FeedbackResponse): user: Optional[UserResponse] = None +class UserSuggestionForm(BaseModel): + content: str + contact: Optional[str] = None + + @router.get("/feedbacks/all", response_model=list[FeedbackUserResponse]) async def get_all_feedbacks(user=Depends(get_admin_user)): feedbacks = Feedbacks.get_all_feedbacks() @@ -127,6 +137,56 @@ async def create_feedback( return feedback +@router.post("/feedback/suggestion", response_model=UserFeedbackModel) +async def create_suggestion_feedback( + form_data: UserSuggestionForm, user=Depends(get_verified_user) +): + """用户主动反馈入口:带 4 小时冷却期,命中则返回 429。""" + content = (form_data.content or "").strip() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="反馈内容不能为空", + ) + + # 4 小时限流 + recent = UserFeedbacks.get_recent_within(user.id, seconds=4 * 60 * 60) + if recent: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="您最近反馈过,请再等等一天", + ) + + feedback = UserFeedbacks.create( + user_id=user.id, + content=content, + contact=(form_data.contact or "").strip() or None, + ) + if not feedback: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(), + ) + return feedback + + +@router.get("/feedbacks/suggestion", response_model=list[UserFeedbackModel]) +async def list_user_suggestions(user=Depends(get_admin_user)): + """管理员查看所有用户反馈。""" + return UserFeedbacks.list_all() + + +@router.delete("/feedback/suggestion/{id}") +async def delete_user_suggestion(id: str, user=Depends(get_admin_user)): + """管理员删除单条用户反馈。""" + success = UserFeedbacks.delete_by_id(id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + return True + + @router.get("/feedback/{id}", response_model=FeedbackModel) async def get_feedback_by_id(id: str, user=Depends(get_verified_user)): if user.role == "admin": diff --git a/src/lib/apis/evaluations/index.ts b/src/lib/apis/evaluations/index.ts index 96a689fcb1..2545f429aa 100644 --- a/src/lib/apis/evaluations/index.ts +++ b/src/lib/apis/evaluations/index.ts @@ -155,6 +155,35 @@ export const createNewFeedback = async (token: string, feedback: object) => { return res; }; +export const createUserSuggestionFeedback = async (token: string, body: { content: string; contact?: string }) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedback/suggestion`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify(body) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail ?? err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getFeedbackById = async (token: string, feedbackId: string) => { let error = null; diff --git a/src/lib/components/common/UserSuggestionModal.svelte b/src/lib/components/common/UserSuggestionModal.svelte new file mode 100644 index 0000000000..3a12d69a18 --- /dev/null +++ b/src/lib/components/common/UserSuggestionModal.svelte @@ -0,0 +1,93 @@ + + + +
+
+ {$i18n.t('反馈') ?? '反馈'} +
+ +
+ +