-
-
- }>
- 开启新对话
-
+
+
+
AI 助手
+ 创建项目内普通会话;不会自动生成任务、笔记或资料。
+
+
-
- {groupedSessions.length ? (
- groupedSessions.map((group) => (
-
- {group.label}
- {group.items.map((session, index) => (
-
- ))}
-
- ))
- ) : (
- 暂无对话
- )}
-
+ {error ? setError('')} /> : null}
+
+
+
+
+ {sessions.length ? (
+
+ {sessions.map((session) => (
+
+ ))}
+
+ ) : loading ? null : }
+
-
-
选择专家,开始对话
-
-
-
-
-
}>
- DeepSeek V4.0 Flash
-
-
- } />
- } />
-
+
+
+
创建会话
+ 会话状态为“待开始”只表示入口已建立,不代表 AI 已完成处理。
-
+
+
+
} loading={creating} onClick={() => void createSession()}>
+ 创建会话
+
+
AI 输出如需转为正式对象,必须另行确认。
+
)
}
-function groupSessions(sessions: AISession[]) {
- const labels = ['今天', '昨天', '7 天内']
- return labels.map((label) => ({
- label,
- items: sessions.filter((session, index) => session.time.includes(label) || (!labels.some((item) => session.time.includes(item)) && labels.indexOf(label) === fallbackGroupIndex(index))),
- })).filter((group) => group.items.length > 0)
-}
-
-function fallbackGroupIndex(index: number) {
- if (index === 0) return 0
- if (index === 1) return 1
- return 2
+function sessionStatusLabel(status: string) {
+ if (status === 'ready') return '待开始'
+ if (status === 'failed') return '创建失败'
+ return status || '未知状态'
}
diff --git a/apps/web_v1/src/pages/projects/project-channel-page.tsx b/apps/web_v1/src/pages/projects/project-channel-page.tsx
index 5e64aa2..6809187 100644
--- a/apps/web_v1/src/pages/projects/project-channel-page.tsx
+++ b/apps/web_v1/src/pages/projects/project-channel-page.tsx
@@ -8,6 +8,7 @@ import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox'
+import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
export function ProjectChannelPage({
activeChannel,
@@ -23,6 +24,8 @@ export function ProjectChannelPage({
onUpdateTask,
onAnalyzeInbox,
onConfirmInbox,
+ onListAISessions,
+ onCreateAISession,
}: {
activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace
@@ -37,6 +40,8 @@ export function ProjectChannelPage({
onUpdateTask: (update: ProjectTaskUpdate) => void
onAnalyzeInbox: (inboxId: string) => Promise
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise
+ onListAISessions: (projectId: string) => Promise
+ onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise
}) {
switch (activeChannel) {
case 'inbox':
@@ -44,7 +49,7 @@ export function ProjectChannelPage({
case 'tasks':
return
case 'ai':
- return
+ return
case 'notes':
return
case 'cron':
diff --git a/apps/web_v1/src/pages/projects/project-cron.tsx b/apps/web_v1/src/pages/projects/project-cron.tsx
index f65547b..e33853e 100644
--- a/apps/web_v1/src/pages/projects/project-cron.tsx
+++ b/apps/web_v1/src/pages/projects/project-cron.tsx
@@ -14,7 +14,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
计划任务
- {project.name} 的定时任务、自动检查和周期性 AI 工作。
+ {project.name} 的计划任务元数据与提醒安排;当前不会自主执行 AI 工作。
} onClick={onCreateCronPlan}>新建计划
diff --git a/apps/web_v1/src/pages/projects/project-new-channel.tsx b/apps/web_v1/src/pages/projects/project-new-channel.tsx
index 8d97662..8cb4215 100644
--- a/apps/web_v1/src/pages/projects/project-new-channel.tsx
+++ b/apps/web_v1/src/pages/projects/project-new-channel.tsx
@@ -1,9 +1,7 @@
-import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
-import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
+import { Card, Empty, Typography } from '@arco-design/web-react'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
-const { TextArea } = Input
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
return (
@@ -11,51 +9,11 @@ export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: Projec
新建频道
- {activeWorkspace.project.name} 的频道入口配置,用于接入外部页面、资料集合或项目流程。
+ {activeWorkspace.project.name} 暂不支持自定义频道或外部链接接入。
-
}>保存频道
-
-
-
-
-
-
-
-
-
-
-
-
-
创建后可接入的内容
- 配置预览
-
-
- 外部资料库
- 项目流程页
- RSS/探索结果
- AI智能体上下文
-
+
)
diff --git a/apps/web_v1/src/pages/projects/project-statusbar.tsx b/apps/web_v1/src/pages/projects/project-statusbar.tsx
index a87c233..b03e286 100644
--- a/apps/web_v1/src/pages/projects/project-statusbar.tsx
+++ b/apps/web_v1/src/pages/projects/project-statusbar.tsx
@@ -1,115 +1,19 @@
-import { useState } from 'react'
-import { Avatar, Button, Card, Form, Input, Layout, Modal, Radio, Space, Typography } from '@arco-design/web-react'
-import {
- IconApps,
- IconCalendar,
- IconRobot,
- IconStorage,
- IconUser,
-} from '@arco-design/web-react/icon'
+import { Layout, Space } from '@arco-design/web-react'
+import { IconCheckCircle } from '@arco-design/web-react/icon'
const { Footer } = Layout
-const { Text, Title } = Typography
-
-const plans = [
- { id: 'starter', name: '基础版', price: '¥29/月', desc: '个人项目、基础智能体与 20GB 存储。' },
- { id: 'pro', name: '专业版', price: '¥99/月', desc: '团队协作、高级智能体与 200GB 存储。' },
- { id: 'team', name: '团队版', price: '¥299/月', desc: '成员管理、审计日志与私有化部署支持。' },
-]
export function ProjectStatusbar() {
- const [profileOpen, setProfileOpen] = useState(false)
- const [upgradeOpen, setUpgradeOpen] = useState(false)
- const [selectedPlan, setSelectedPlan] = useState('pro')
-
return (
- <>
-
-
-
setProfileOpen(false)}
- onOk={() => setProfileOpen(false)}
- okText="保存"
- cancelText="取消"
- >
-
-
- 张
- }>更换头像
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
setUpgradeOpen(false)}
- onOk={() => setUpgradeOpen(false)}
- okText="去支付"
- cancelText="取消"
- >
-
- {plans.map((plan) => (
-
-
-
-
{plan.name}
- {plan.price}
- {plan.desc}
-
-
-
- ))}
-
-
- 支付方式
-
-
-
-
-
-
-
- >
+
)
}
-
-function IconInteractionFallback() {
- return
-}
diff --git a/apps/web_v1/src/pages/projects/project-topbar.tsx b/apps/web_v1/src/pages/projects/project-topbar.tsx
index 82835ca..26d1098 100644
--- a/apps/web_v1/src/pages/projects/project-topbar.tsx
+++ b/apps/web_v1/src/pages/projects/project-topbar.tsx
@@ -28,7 +28,6 @@ export function ProjectTopbar({
onSearch: () => void
onSelectSearchResult: (result: SearchResultDTO) => void
}) {
- const desktop = isDesktopRuntime()
const [popupVisible, setPopupVisible] = useState(false)
useEffect(() => {
@@ -99,13 +98,6 @@ export function ProjectTopbar({
: } onClick={onToggleTheme} />
- {desktop && (
- <>
- } />
- } />
- >
- )}
- } />
)
@@ -117,15 +109,3 @@ function searchTypeLabel(type: string) {
if (type === 'note') return '笔记'
return '未知'
}
-
-function isDesktopRuntime() {
- return typeof window !== 'undefined' && '__TAURI__' in window
-}
-
-function DockIcon({ side }: { side: 'left' | 'right' }) {
- return (
-
-
-
- )
-}
diff --git a/apps/web_v1/src/pages/workspace-explore.tsx b/apps/web_v1/src/pages/workspace-explore.tsx
index 227e320..3ad310e 100644
--- a/apps/web_v1/src/pages/workspace-explore.tsx
+++ b/apps/web_v1/src/pages/workspace-explore.tsx
@@ -1,223 +1,19 @@
-import { useEffect, useMemo, useState } from 'react'
-import type { ReactNode } from 'react'
-import { Button, Card, Empty, Grid, Space, Typography } from '@arco-design/web-react'
-import {
- IconBook,
- IconCheckCircle,
- IconCompass,
- IconDelete,
- IconEdit,
- IconFile,
- IconLink,
- IconRefresh,
- IconStar,
- IconStorage,
-} from '@arco-design/web-react/icon'
-import type { InboxItem, Project, ProjectWorkspace } from './projects/project-types'
+import { Card, Empty, Typography } from '@arco-design/web-react'
-const { Row, Col } = Grid
-const { Title, Text, Paragraph } = Typography
-
-type DataSourceID = 'all' | 'manual' | 'requirements' | 'architecture'
-
-type ExploreArticle = InboxItem & {
- project: Project
- sourceID: DataSourceID
-}
-
-type DataSourceCard = {
- id: DataSourceID
- name: string
- count: number
- icon: ReactNode
- color: string
-}
-
-const SOURCE_META: Record
= {
- all: { name: '全部', icon: , color: 'blue' },
- manual: { name: '手动收集', icon: , color: 'green' },
- requirements: { name: '需求文档', icon: , color: 'orange' },
- architecture: { name: '架构讨论', icon: , color: 'purple' },
-}
-
-export function WorkspaceExplorePage({
- workspaces,
- onSelectItem,
-}: {
- workspaces: ProjectWorkspace[]
- onSelectItem: (title: string) => void
-}) {
- const articles = useMemo(
- () =>
- workspaces.flatMap((workspace) =>
- workspace.inbox.map((item) => ({
- ...item,
- project: workspace.project,
- sourceID: detectSource(item),
- })),
- ),
- [workspaces],
- )
- const [activeSourceID, setActiveSourceID] = useState('all')
- const filteredArticles = useMemo(
- () => (activeSourceID === 'all' ? articles : articles.filter((article) => article.sourceID === activeSourceID)),
- [activeSourceID, articles],
- )
- const sources = useMemo(() => dataSources(articles), [articles])
- const [activeArticleID, setActiveArticleID] = useState(filteredArticles[0]?.id ?? null)
-
- useEffect(() => {
- if (filteredArticles.length === 0) {
- setActiveArticleID(null)
- return
- }
- if (!activeArticleID || !filteredArticles.some((article) => article.id === activeArticleID)) {
- setActiveArticleID(filteredArticles[0].id)
- }
- }, [activeArticleID, filteredArticles])
-
- const selected = filteredArticles.find((article) => article.id === activeArticleID) ?? filteredArticles[0]
- const activeSource = SOURCE_META[activeSourceID]
+const { Title, Text } = Typography
+export function WorkspaceExplorePage() {
return (
探索
- 多个外部数据源的集中阅读页,采集文章并沉淀到项目。
+ 外部数据源与同步能力不在当前 MVP 范围内。
-
- }>同步数据源
- }>
- 添加数据源
-
-
-
-
- {sources.map((source) => (
-
- setActiveSourceID(source.id)}
- >
- {source.icon}
-
- {source.name}
- {source.count} 条
-
- {source.id !== 'all' ? (
- event.stopPropagation()}>
- } />
- } />
-
- ) : null}
-
-
- ))}
-
-
- {selected ? (
-
-
-
-
-
- {activeSource.icon}
- {activeSource.name}
-
-
- } />
-
-
- {filteredArticles.map((article) => (
-
- ))}
-
-
-
-
-
-
{selected.title}
-
- } />
- } />
- } />
-
-
-
-
-
- {sourceInitial(SOURCE_META[selected.sourceID].name)}
-
- {SOURCE_META[selected.sourceID].name}
- {selected.project.name}
- {selected.time}
-
- {selected.summary || '暂无正文'}
-
- 推荐:将外部文章中的项目机会、风险提示和资料线索归档到对应项目,形成可追踪的计划和知识资产。
-
-
-
-
- ) : (
-
-
-
- )}
+
+
+
)
}
-
-function dataSources(articles: ExploreArticle[]): DataSourceCard[] {
- const counts = new Map([
- ['all', articles.length],
- ['manual', 0],
- ['requirements', 0],
- ['architecture', 0],
- ])
- articles.forEach((article) => counts.set(article.sourceID, (counts.get(article.sourceID) ?? 0) + 1))
-
- return (Object.keys(SOURCE_META) as DataSourceID[]).map((id) => ({
- id,
- ...SOURCE_META[id],
- count: counts.get(id) ?? 0,
- }))
-}
-
-function detectSource(article: InboxItem): DataSourceID {
- const text = `${article.title} ${article.meta} ${article.tag} ${article.summary}`
- if (/架构|技术方案|系统设计|architecture/i.test(text)) {
- return 'architecture'
- }
- if (/需求|PRD|产品文档|requirement/i.test(text)) {
- return 'requirements'
- }
- return 'manual'
-}
-
-function sourceInitial(name: string) {
- return name.trim().slice(0, 1) || '源'
-}
diff --git a/apps/web_v1/src/pages/workspace-home.tsx b/apps/web_v1/src/pages/workspace-home.tsx
index 41e9645..bcc40d8 100644
--- a/apps/web_v1/src/pages/workspace-home.tsx
+++ b/apps/web_v1/src/pages/workspace-home.tsx
@@ -9,6 +9,7 @@ import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { SearchResultDTO } from '../api/search'
import type { InboxSuggestionDTO } from '../api/inbox'
+import type { AISessionDTO, CreateAISessionInput } from '../api/ai'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
@@ -44,6 +45,8 @@ export function ProjectPage({
onSelectSearchResult,
onAnalyzeInbox,
onConfirmInbox,
+ onListAISessions,
+ onCreateAISession,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
@@ -75,6 +78,8 @@ export function ProjectPage({
onSelectSearchResult: (result: SearchResultDTO) => void
onAnalyzeInbox: (inboxId: string) => Promise
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise
+ onListAISessions: (projectId: string) => Promise
+ onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise
}) {
const isProject = activeView === 'project'
const projects = workspaces.map((workspace) => workspace.project)
@@ -130,7 +135,7 @@ export function ProjectPage({
{activeView === 'workspace' ? (
) : activeView === 'workspace-explore' ? (
-
+
) : (
)}
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
index 989ce8c..852ea04 100644
--- a/backend/cmd/api/main.go
+++ b/backend/cmd/api/main.go
@@ -5,6 +5,7 @@ import (
"senlinai-agent/backend/internal/config"
"senlinai-agent/backend/internal/httpx"
+ "senlinai-agent/backend/internal/logic/ai"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/files"
"senlinai-agent/backend/internal/logic/inbox"
@@ -33,6 +34,8 @@ func main() {
fileHandler := files.NewHandler(fileService, cfg.MaxUploadBytes)
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
searchHandler := search.NewHandler(search.NewService(models.DBService))
+ aiGateway := ai.NewGatewayWithSecret(cfg.SystemAIKey, cfg.AIKeyEncryptionSecret)
+ aiHandler := ai.NewHandler(ai.NewSessionService(aiGateway))
authHandler := auth.NewHandler(authService)
appRouter := httpx.NewProtectedRouter(
cfg,
@@ -45,6 +48,7 @@ func main() {
fileHandler,
inboxHandler,
searchHandler,
+ aiHandler,
)
if err := appRouter.Run(":" + cfg.Port); err != nil {
log.Fatal(err)
diff --git a/backend/internal/logic/ai/gateway.go b/backend/internal/logic/ai/gateway.go
index f1fdd4d..d8889e5 100644
--- a/backend/internal/logic/ai/gateway.go
+++ b/backend/internal/logic/ai/gateway.go
@@ -10,6 +10,7 @@ import (
"io"
"time"
+ "gorm.io/gorm"
"gorm.io/gorm/clause"
"senlinai-agent/backend/internal/models"
)
@@ -25,6 +26,11 @@ type SelectedKey struct {
KeyType string
}
+var (
+ ErrAIKeyMissing = errors.New("no ai key available")
+ ErrAIRateLimited = errors.New("ai rate limit exceeded")
+)
+
func NewGateway(systemKey string) *Gateway {
return NewGatewayWithSecret(systemKey, "development-ai-key-secret-change-me")
}
@@ -47,15 +53,19 @@ func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error
func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
var userKey models.SenlinAgentAIKey
- if err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error; err == nil {
+ err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error
+ if err == nil {
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
if err != nil {
return SelectedKey{}, err
}
return SelectedKey{Provider: userKey.Provider, APIKey: apiKey, KeyType: "user"}, nil
}
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return SelectedKey{}, err
+ }
if g.systemKey == "" {
- return SelectedKey{}, errors.New("no ai key available")
+ return SelectedKey{}, ErrAIKeyMissing
}
return SelectedKey{Provider: "openai", APIKey: g.systemKey, KeyType: "system"}, nil
}
@@ -82,7 +92,7 @@ func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window t
return err
}
if count >= int64(limit) {
- return errors.New("ai rate limit exceeded")
+ return ErrAIRateLimited
}
return nil
}
diff --git a/backend/internal/logic/ai/gateway_test.go b/backend/internal/logic/ai/gateway_test.go
index e66f6fb..b85c21e 100644
--- a/backend/internal/logic/ai/gateway_test.go
+++ b/backend/internal/logic/ai/gateway_test.go
@@ -8,6 +8,7 @@ import (
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
+ "gorm.io/gorm/logger"
"senlinai-agent/backend/internal/models"
)
@@ -85,20 +86,25 @@ func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
}
func TestCreateAISession(t *testing.T) {
- newTestDB(t)
- service := NewSessionService()
+ database := newTestDB(t)
+ user := models.SenlinAgentUser{Email: "session@example.com", DisplayName: "Session User", PasswordHash: "hash"}
+ require.NoError(t, database.Create(&user).Error)
+ project := models.SenlinAgentProject{OwnerID: user.ID, Name: "Session Project", Identifier: "SESSION"}
+ require.NoError(t, database.Create(&project).Error)
+ service := NewSessionService(NewGateway("system-key"))
- session, err := service.Create(7, 3, "报价分析")
+ session, err := service.Create(user.ID, project.Identity, "报价分析", "询价上下文")
require.NoError(t, err)
- require.Equal(t, uint(7), session.ProjectID)
- require.Equal(t, uint(3), session.CreatedBy)
+ require.Equal(t, project.ID, session.ProjectID)
+ require.Equal(t, user.ID, session.CreatedBy)
require.Equal(t, "报价分析", session.Title)
+ require.Equal(t, "ready", session.Status)
}
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
- database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, models.AutoMigrate(database))
models.DBService = database
diff --git a/backend/internal/logic/ai/handlers.go b/backend/internal/logic/ai/handlers.go
new file mode 100644
index 0000000..d31d9aa
--- /dev/null
+++ b/backend/internal/logic/ai/handlers.go
@@ -0,0 +1,120 @@
+package ai
+
+import (
+ "errors"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+ "senlinai-agent/backend/internal/httpx"
+ "senlinai-agent/backend/internal/logic/auth"
+ "senlinai-agent/backend/internal/models"
+)
+
+// SessionDTO 是普通项目 AI 会话的公开契约,不携带数据库主键或自动创建对象的 ID。
+type SessionDTO struct {
+ ID string `json:"id"`
+ ProjectID string `json:"projectId"`
+ Title string `json:"title"`
+ Context string `json:"context"`
+ Status string `json:"status"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type createSessionRequest struct {
+ Title string `json:"title"`
+ Context string `json:"context"`
+}
+
+// Handler 注册受认证、受项目所有权保护的 AI 会话接口。
+type Handler struct {
+ service *SessionService
+}
+
+func NewHandler(service *SessionService) *Handler {
+ return &Handler{service: service}
+}
+
+func (h *Handler) Register(router gin.IRouter) {
+ router.GET("/projects/:projectId/ai-sessions", h.list)
+ router.POST("/projects/:projectId/ai-sessions", h.create)
+}
+
+func (h *Handler) list(c *gin.Context) {
+ userID, projectIdentity, ok := aiRequestContext(c)
+ if !ok {
+ return
+ }
+ sessions, err := h.service.List(userID, projectIdentity)
+ if err != nil {
+ writeAIError(c, err)
+ return
+ }
+ items := make([]SessionDTO, 0, len(sessions))
+ for _, session := range sessions {
+ items = append(items, sessionDTO(session))
+ }
+ c.JSON(http.StatusOK, items)
+}
+
+func (h *Handler) create(c *gin.Context) {
+ userID, projectIdentity, ok := aiRequestContext(c)
+ if !ok {
+ return
+ }
+ var input createSessionRequest
+ if err := c.ShouldBindJSON(&input); err != nil {
+ httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
+ return
+ }
+ session, err := h.service.Create(userID, projectIdentity, input.Title, input.Context)
+ if err != nil {
+ writeAIError(c, err)
+ return
+ }
+ c.JSON(http.StatusCreated, sessionDTO(*session))
+}
+
+func aiRequestContext(c *gin.Context) (uint, string, bool) {
+ userID, ok := auth.CurrentUserID(c)
+ if !ok {
+ httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
+ return 0, "", false
+ }
+ projectIdentity, ok := httpx.IdentityParam(c, "projectId")
+ if !ok {
+ return 0, "", false
+ }
+ return userID, projectIdentity, true
+}
+
+func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
+ return SessionDTO{
+ ID: session.Identity,
+ ProjectID: session.ProjectIdentity,
+ Title: session.Title,
+ Context: session.Context,
+ Status: aiSessionStatus(session),
+ CreatedAt: session.CreatedAt.UTC(),
+ UpdatedAt: session.UpdatedAt.UTC(),
+ }
+}
+
+func writeAIError(c *gin.Context, err error) {
+ switch {
+ case errors.Is(err, gorm.ErrRecordNotFound):
+ httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在或无权访问")
+ case errors.Is(err, ErrInvalidSession):
+ httpx.Error(c, http.StatusBadRequest, "invalid_request", "请输入 AI 会话标题")
+ case errors.Is(err, ErrAIRateLimited):
+ httpx.Error(c, http.StatusTooManyRequests, "ai_rate_limited", "AI 请求过于频繁,请稍后重试")
+ case errors.Is(err, ErrAIKeyMissing):
+ httpx.Error(c, http.StatusServiceUnavailable, "ai_key_missing", "尚未配置可用的 AI 密钥")
+ default:
+ log.Printf("ai session request failed: %v", err)
+ httpx.Error(c, http.StatusInternalServerError, "internal_error", "AI 会话操作失败,请稍后重试")
+ }
+}
diff --git a/backend/internal/logic/ai/handlers_test.go b/backend/internal/logic/ai/handlers_test.go
new file mode 100644
index 0000000..fa552d7
--- /dev/null
+++ b/backend/internal/logic/ai/handlers_test.go
@@ -0,0 +1,278 @@
+package ai
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/glebarez/sqlite"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+ "senlinai-agent/backend/internal/config"
+ "senlinai-agent/backend/internal/httpx"
+ "senlinai-agent/backend/internal/models"
+)
+
+func TestAISessionHandlersRequireOwnedProject(t *testing.T) {
+ database := newAIHandlerTestDB(t)
+ owner := createAIHandlerUser(t, database, "owner@example.com")
+ intruder := createAIHandlerUser(t, database, "intruder@example.com")
+ project := createAIHandlerProject(t, database, owner.ID, "PRIVATE")
+ gateway := &recordingSessionGateway{}
+ router := aiHandlerTestRouter(intruder.ID, gateway)
+
+ for _, request := range []*http.Request{
+ authenticatedAIRequest(t, http.MethodGet, "/api/v1/projects/"+project.Identity+"/ai-sessions", nil),
+ authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
+ "title": "越权会话", "context": "不得创建",
+ }),
+ } {
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNotFound, recorder.Code)
+ var payload httpx.ErrorEnvelope
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
+ require.Equal(t, "not_found", payload.Error.Code)
+ require.Equal(t, "项目不存在或无权访问", payload.Error.Message)
+ }
+
+ var count int64
+ require.NoError(t, database.Model(&models.SenlinAgentAISession{}).Count(&count).Error)
+ require.Zero(t, count)
+ require.Empty(t, gateway.steps)
+}
+
+func TestCreateAISessionChecksRateLimitBeforeSelectingProvider(t *testing.T) {
+ database := newAIHandlerTestDB(t)
+ owner := createAIHandlerUser(t, database, "owner@example.com")
+ project := createAIHandlerProject(t, database, owner.ID, "RATE")
+ gateway := &recordingSessionGateway{
+ selected: SelectedKey{Provider: "openai", APIKey: "system-key", KeyType: "system"},
+ }
+ router := aiHandlerTestRouter(owner.ID, gateway)
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
+ "title": "限流顺序", "context": "只创建受控会话",
+ }))
+
+ require.Equal(t, http.StatusCreated, recorder.Code)
+ require.Equal(t, []string{"rate", "select", "record"}, gateway.steps)
+}
+
+func TestCreateAISessionReturnsRateLimitBeforeMissingKeyAndAuditsFailure(t *testing.T) {
+ database := newAIHandlerTestDB(t)
+ owner := createAIHandlerUser(t, database, "owner@example.com")
+ project := createAIHandlerProject(t, database, owner.ID, "LIMITED")
+ gateway := NewGatewayWithSecret("", "test-encryption-secret")
+ for range aiSessionCreateLimit {
+ require.NoError(t, gateway.RecordCall(owner.ID, "openai", "system", "ai_session_create", "ready", ""))
+ }
+ router := aiHandlerTestRouter(owner.ID, gateway)
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
+ "title": "超过限额", "context": "必须先返回限流",
+ }))
+
+ require.Equal(t, http.StatusTooManyRequests, recorder.Code)
+ var payload httpx.ErrorEnvelope
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
+ require.Equal(t, "ai_rate_limited", payload.Error.Code)
+ require.Equal(t, "AI 请求过于频繁,请稍后重试", payload.Error.Message)
+ var latest models.SenlinAgentAICallLog
+ require.NoError(t, database.Order("id desc").First(&latest).Error)
+ require.Equal(t, "none", latest.Provider)
+ require.Equal(t, "none", latest.UsedKeyType)
+ require.Equal(t, "ai_session_create", latest.Action)
+ require.Equal(t, "failed", latest.Status)
+ require.Equal(t, "ai_rate_limited", latest.Error)
+}
+
+func TestCreateAISessionWithoutKeyReturnsAuditedErrorAndCreatesNoFormalObjects(t *testing.T) {
+ database := newAIHandlerTestDB(t)
+ owner := createAIHandlerUser(t, database, "owner@example.com")
+ project := createAIHandlerProject(t, database, owner.ID, "NO_KEY")
+ router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("", "test-encryption-secret"))
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
+ "title": "缺少密钥", "context": "不得伪装为已完成",
+ }))
+
+ require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
+ var payload httpx.ErrorEnvelope
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
+ require.Equal(t, "ai_key_missing", payload.Error.Code)
+ require.Equal(t, "尚未配置可用的 AI 密钥", payload.Error.Message)
+ for _, model := range []any{
+ &models.SenlinAgentAISession{},
+ &models.SenlinAgentTask{},
+ &models.SenlinAgentNote{},
+ &models.SenlinAgentSource{},
+ } {
+ var count int64
+ require.NoError(t, database.Model(model).Count(&count).Error)
+ require.Zero(t, count)
+ }
+ var call models.SenlinAgentAICallLog
+ require.NoError(t, database.First(&call).Error)
+ require.Equal(t, owner.ID, call.UserID)
+ require.Equal(t, "none", call.Provider)
+ require.Equal(t, "none", call.UsedKeyType)
+ require.Equal(t, "ai_session_create", call.Action)
+ require.Equal(t, "failed", call.Status)
+ require.Equal(t, "ai_key_missing", call.Error)
+}
+
+func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObjectIDs(t *testing.T) {
+ database := newAIHandlerTestDB(t)
+ owner := createAIHandlerUser(t, database, "owner@example.com")
+ project := createAIHandlerProject(t, database, owner.ID, "CREATE")
+ router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("system-key", "test-encryption-secret"))
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
+ "title": "报价分析", "context": "仅整理会话上下文",
+ }))
+
+ require.Equal(t, http.StatusCreated, recorder.Code)
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
+ require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload))
+ identity, err := uuid.Parse(payload["id"].(string))
+ require.NoError(t, err)
+ require.Equal(t, uuid.Version(7), identity.Version())
+ require.Equal(t, project.Identity, payload["projectId"])
+ require.Equal(t, "报价分析", payload["title"])
+ require.Equal(t, "仅整理会话上下文", payload["context"])
+ require.Equal(t, "ready", payload["status"])
+ for _, forbidden := range []string{"taskId", "noteId", "sourceId", "createdTaskId", "createdNoteId", "createdSourceId"} {
+ require.NotContains(t, payload, forbidden)
+ }
+
+ var call models.SenlinAgentAICallLog
+ require.NoError(t, database.First(&call).Error)
+ require.Equal(t, "openai", call.Provider)
+ require.Equal(t, "system", call.UsedKeyType)
+ require.Equal(t, "ai_session_create", call.Action)
+ require.Equal(t, "ready", call.Status)
+ require.Empty(t, call.Error)
+ for _, model := range []any{&models.SenlinAgentTask{}, &models.SenlinAgentNote{}, &models.SenlinAgentSource{}} {
+ var count int64
+ require.NoError(t, database.Model(model).Count(&count).Error)
+ require.Zero(t, count)
+ }
+}
+
+func TestListAISessionsReturnsOnlyOwnedProjectIdentityDTOs(t *testing.T) {
+ database := newAIHandlerTestDB(t)
+ owner := createAIHandlerUser(t, database, "owner@example.com")
+ project := createAIHandlerProject(t, database, owner.ID, "LIST")
+ otherProject := createAIHandlerProject(t, database, owner.ID, "OTHER")
+ require.NoError(t, database.Create(&models.SenlinAgentAISession{
+ ProjectID: project.ID, CreatedBy: owner.ID, Title: "目标会话", Context: "项目上下文",
+ }).Error)
+ require.NoError(t, database.Create(&models.SenlinAgentAISession{
+ ProjectID: otherProject.ID, CreatedBy: owner.ID, Title: "其他会话", Context: "不得混入",
+ }).Error)
+ router := aiHandlerTestRouter(owner.ID, &recordingSessionGateway{})
+ recorder := httptest.NewRecorder()
+
+ router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/projects/"+project.Identity+"/ai-sessions", nil))
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var payload []map[string]any
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
+ require.Len(t, payload, 1)
+ require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload[0]))
+ require.Equal(t, project.Identity, payload[0]["projectId"])
+ require.Equal(t, "目标会话", payload[0]["title"])
+ require.Equal(t, "ready", payload[0]["status"])
+}
+
+type recordingSessionGateway struct {
+ steps []string
+ selected SelectedKey
+ rateErr error
+ selectErr error
+}
+
+func (g *recordingSessionGateway) CheckRateLimit(uint, string, int, time.Duration) error {
+ g.steps = append(g.steps, "rate")
+ return g.rateErr
+}
+
+func (g *recordingSessionGateway) SelectKey(uint) (SelectedKey, error) {
+ g.steps = append(g.steps, "select")
+ return g.selected, g.selectErr
+}
+
+func (g *recordingSessionGateway) RecordCall(uint, string, string, string, string, string) error {
+ g.steps = append(g.steps, "record")
+ return nil
+}
+
+func newAIHandlerTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
+ require.NoError(t, err)
+ require.NoError(t, models.AutoMigrate(database))
+ models.DBService = database
+ return database
+}
+
+func createAIHandlerUser(t *testing.T, database *gorm.DB, email string) models.SenlinAgentUser {
+ t.Helper()
+ user := models.SenlinAgentUser{Email: email, DisplayName: email, PasswordHash: "hash"}
+ require.NoError(t, database.Create(&user).Error)
+ return user
+}
+
+func createAIHandlerProject(t *testing.T, database *gorm.DB, ownerID uint, identifier string) models.SenlinAgentProject {
+ t.Helper()
+ project := models.SenlinAgentProject{OwnerID: ownerID, Name: identifier, Identifier: identifier}
+ require.NoError(t, database.Create(&project).Error)
+ return project
+}
+
+func aiHandlerTestRouter(userID uint, gateway sessionGateway) http.Handler {
+ return httpx.NewProtectedRouter(
+ config.Config{Env: "test"},
+ func(string) (uint, error) { return userID, nil },
+ NewHandler(NewSessionService(gateway)),
+ )
+}
+
+func authenticatedAIRequest(t *testing.T, method, path string, body any) *http.Request {
+ t.Helper()
+ var requestBody *bytes.Reader
+ if body == nil {
+ requestBody = bytes.NewReader(nil)
+ } else {
+ encoded, err := json.Marshal(body)
+ require.NoError(t, err)
+ requestBody = bytes.NewReader(encoded)
+ }
+ request := httptest.NewRequest(method, path, requestBody)
+ request.Header.Set("Authorization", "Bearer test-token")
+ if body != nil {
+ request.Header.Set("Content-Type", "application/json")
+ }
+ return request
+}
+
+func aiMapKeys(values map[string]any) []string {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ return keys
+}
diff --git a/backend/internal/logic/ai/sessions.go b/backend/internal/logic/ai/sessions.go
index f48c02c..3e59d48 100644
--- a/backend/internal/logic/ai/sessions.go
+++ b/backend/internal/logic/ai/sessions.go
@@ -2,23 +2,118 @@ package ai
import (
"errors"
+ "fmt"
"strings"
+ "time"
+ "senlinai-agent/backend/internal/logic/projects"
"senlinai-agent/backend/internal/models"
)
+const (
+ aiSessionCreateAction = "ai_session_create"
+ aiSessionCreateLimit = 20
+ defaultSessionStatus = "ready"
+)
+
+var (
+ ErrInvalidSession = errors.New("invalid ai session")
+)
+
+type sessionGateway interface {
+ CheckRateLimit(userID uint, action string, limit int, window time.Duration) error
+ SelectKey(userID uint) (SelectedKey, error)
+ RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error
+}
+
+// SessionService 只管理项目内普通 AI 会话;它不会把上下文自动转换为任务、笔记或资料。
type SessionService struct {
+ gateway sessionGateway
}
-func NewSessionService() *SessionService {
- return &SessionService{}
+func NewSessionService(gateway sessionGateway) *SessionService {
+ return &SessionService{gateway: gateway}
}
-func (s *SessionService) Create(projectID uint, userID uint, title string) (*models.SenlinAgentAISession, error) {
- title = strings.TrimSpace(title)
- if title == "" {
- return nil, errors.New("session title is required")
+// List 在项目 owner 校验后返回该项目的会话,内部自增 ID 不离开服务边界。
+func (s *SessionService) List(userID uint, projectIdentity string) ([]models.SenlinAgentAISession, error) {
+ project, err := projects.FindOwnedProject(userID, projectIdentity)
+ if err != nil {
+ return nil, err
}
- session := &models.SenlinAgentAISession{ProjectID: projectID, CreatedBy: userID, Title: title}
- return session, models.DBService.Create(session).Error
+ var sessions []models.SenlinAgentAISession
+ if err := models.DBService.Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil {
+ return nil, err
+ }
+ if sessions == nil {
+ sessions = []models.SenlinAgentAISession{}
+ }
+ return sessions, nil
+}
+
+// Create 先校验项目,再限流,最后才选择 provider/key;会话创建不会生成任何正式业务对象。
+func (s *SessionService) Create(userID uint, projectIdentity, title, context string) (*models.SenlinAgentAISession, error) {
+ title = strings.TrimSpace(title)
+ context = strings.TrimSpace(context)
+ if title == "" {
+ return nil, ErrInvalidSession
+ }
+ project, err := projects.FindOwnedProject(userID, projectIdentity)
+ if err != nil {
+ return nil, err
+ }
+ if s.gateway == nil {
+ return nil, errors.New("ai gateway is required")
+ }
+
+ if err := s.gateway.CheckRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil {
+ if errors.Is(err, ErrAIRateLimited) {
+ if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "ai_rate_limited"); auditErr != nil {
+ return nil, fmt.Errorf("record ai rate limit failure: %w", auditErr)
+ }
+ return nil, ErrAIRateLimited
+ }
+ if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "rate_limit_check_failed"); auditErr != nil {
+ return nil, fmt.Errorf("check rate limit: %v; record failure: %w", err, auditErr)
+ }
+ return nil, err
+ }
+
+ selected, err := s.gateway.SelectKey(userID)
+ if err != nil {
+ code := "provider_selection_failed"
+ if errors.Is(err, ErrAIKeyMissing) {
+ code = "ai_key_missing"
+ }
+ if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", code); auditErr != nil {
+ return nil, fmt.Errorf("select ai key: %v; record failure: %w", err, auditErr)
+ }
+ return nil, err
+ }
+
+ session := models.SenlinAgentAISession{
+ ProjectID: project.ID,
+ CreatedBy: userID,
+ Title: title,
+ Context: context,
+ Status: defaultSessionStatus,
+ }
+ if err := models.DBService.Create(&session).Error; err != nil {
+ if auditErr := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, "failed", "session_create_failed"); auditErr != nil {
+ return nil, fmt.Errorf("create ai session: %v; record failure: %w", err, auditErr)
+ }
+ return nil, err
+ }
+ // ready 只表示会话入口已建立,不表示 provider 已回复或任何业务对象已创建。
+ if err := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, defaultSessionStatus, ""); err != nil {
+ return nil, fmt.Errorf("record ai session creation: %w", err)
+ }
+ return &session, nil
+}
+
+func aiSessionStatus(session models.SenlinAgentAISession) string {
+ if status := strings.TrimSpace(session.Status); status != "" {
+ return status
+ }
+ return defaultSessionStatus
}
diff --git a/backend/internal/models/ai_session.go b/backend/internal/models/ai_session.go
index b5c4d58..c65fd2e 100644
--- a/backend/internal/models/ai_session.go
+++ b/backend/internal/models/ai_session.go
@@ -11,6 +11,7 @@ type SenlinAgentAISession struct {
CreatedByIdentity string `gorm:"type:char(36);index"`
Title string `gorm:"not null"`
Context string `gorm:"type:text"`
+ Status string `gorm:"not null;default:ready"`
CreatedAt time.Time
UpdatedAt time.Time
}