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 @@
+
+
+