package main // ai.go 实现 AI 分析编排:会话/消息存储、场景化提示词构建、 // 通过 service/ai 工厂创建 Provider 并以 "ai:stream" 事件流式推送回复。 import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "view/service/ai" ) // ---------- 会话与消息存储 ---------- func (s *Store) ListAIConversations(projectID int64) ([]AIConversation, error) { q := `SELECT c.id,c.project_id,COALESCE(p.name,''),c.provider,c.title,c.created_at,c.updated_at FROM ai_conversations c LEFT JOIN projects p ON p.id=c.project_id` args := []any{} if projectID > 0 { q += ` WHERE c.project_id=?` args = append(args, projectID) } q += ` ORDER BY c.updated_at DESC LIMIT 200` rows, e := s.db.Query(q, args...) if e != nil { return nil, e } defer rows.Close() out := []AIConversation{} for rows.Next() { var x AIConversation if e = rows.Scan(&x.ID, &x.ProjectID, &x.ProjectName, &x.Provider, &x.Title, &x.CreatedAt, &x.UpdatedAt); e != nil { return nil, e } out = append(out, x) } return out, rows.Err() } func (s *Store) CreateAIConversation(projectID int64, provider, title string) (AIConversation, error) { now := nowRFC() res, e := s.db.Exec(`INSERT INTO ai_conversations(project_id,provider,title,created_at,updated_at) VALUES(?,?,?,?,?)`, projectID, provider, title, now, now) if e != nil { return AIConversation{}, e } id, _ := res.LastInsertId() var name string _ = s.db.QueryRow(`SELECT name FROM projects WHERE id=?`, projectID).Scan(&name) return AIConversation{ID: id, ProjectID: projectID, ProjectName: name, Provider: provider, Title: title, CreatedAt: now, UpdatedAt: now}, nil } func (s *Store) TouchAIConversation(id int64, provider string) { _, _ = s.db.Exec(`UPDATE ai_conversations SET updated_at=?,provider=? WHERE id=?`, nowRFC(), provider, id) } func (s *Store) DeleteAIConversation(id int64) error { if _, e := s.db.Exec(`DELETE FROM ai_messages WHERE conversation_id=?`, id); e != nil { return e } _, e := s.db.Exec(`DELETE FROM ai_conversations WHERE id=?`, id) return e } func (s *Store) ListAIMessages(conversationID int64) ([]AIMessage, error) { rows, e := s.db.Query(`SELECT id,conversation_id,role,content,created_at FROM ai_messages WHERE conversation_id=? ORDER BY id`, conversationID) if e != nil { return nil, e } defer rows.Close() out := []AIMessage{} for rows.Next() { var x AIMessage if e = rows.Scan(&x.ID, &x.ConversationID, &x.Role, &x.Content, &x.CreatedAt); e != nil { return nil, e } out = append(out, x) } return out, rows.Err() } func (s *Store) AddAIMessage(conversationID int64, role, content string) (AIMessage, error) { now := nowRFC() res, e := s.db.Exec(`INSERT INTO ai_messages(conversation_id,role,content,created_at) VALUES(?,?,?,?)`, conversationID, role, content, now) if e != nil { return AIMessage{}, e } id, _ := res.LastInsertId() return AIMessage{ID: id, ConversationID: conversationID, Role: role, Content: content, CreatedAt: now}, nil } // ---------- 绑定接口 ---------- func (a *App) ListAIConversations(projectID int64) ([]AIConversation, error) { if e := a.ready(); e != nil { return nil, e } return a.store.ListAIConversations(projectID) } func (a *App) GetAIMessages(conversationID int64) ([]AIMessage, error) { if e := a.ready(); e != nil { return nil, e } return a.store.ListAIMessages(conversationID) } func (a *App) DeleteAIConversation(id int64) error { if e := a.ready(); e != nil { return e } a.stopAIStream(id) return a.store.DeleteAIConversation(id) } // StopAIStream 中止指定会话正在进行的流式回复。 func (a *App) StopAIStream(conversationID int64) error { a.stopAIStream(conversationID) return nil } func (a *App) stopAIStream(conversationID int64) { a.mu.Lock() cancel := a.aiStreams[conversationID] delete(a.aiStreams, conversationID) a.mu.Unlock() if cancel != nil { cancel() } } // aiStreamEvent 是 "ai:stream" 事件载荷。 type aiStreamEvent struct { ConversationID int64 `json:"conversationId"` Delta string `json:"delta,omitempty"` Done bool `json:"done,omitempty"` Error string `json:"error,omitempty"` MessageID int64 `json:"messageId,omitempty"` } // SendAIMessage 发送一条用户消息并启动流式回复。 // conversationID 为 0 时自动创建会话;scenario: chat | project | git | todo | ticket。 func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content string) (AIConversation, error) { if e := a.ready(); e != nil { return AIConversation{}, e } content = strings.TrimSpace(content) if content == "" { return AIConversation{}, errors.New("AI_EMPTY_MESSAGE") } st, e := a.store.Settings() if e != nil { return AIConversation{}, e } provider, e := a.aiProvider() if e != nil { return AIConversation{}, e } if e := a.checkAIPolicy(); e != nil { return AIConversation{}, e } var conv AIConversation if conversationID == 0 { title := content if r := []rune(title); len(r) > 40 { title = string(r[:40]) + "…" } conv, e = a.store.CreateAIConversation(projectID, provider.Name(), title) if e != nil { return AIConversation{}, e } } else { convs, _ := a.store.ListAIConversations(0) for _, c := range convs { if c.ID == conversationID { conv = c break } } if conv.ID == 0 { return AIConversation{}, errors.New("AI_CONVERSATION_NOT_FOUND") } projectID = conv.ProjectID } a.mu.Lock() if _, running := a.aiStreams[conv.ID]; running { a.mu.Unlock() return conv, errors.New("AI_STREAM_RUNNING") } ctx, cancel := context.WithCancel(a.ctx) a.aiStreams[conv.ID] = cancel a.mu.Unlock() // 组装消息:场景系统提示词 + 最近历史 + 本条用户消息。 history, _ := a.store.ListAIMessages(conv.ID) msgs := []ai.Message{{Role: "system", Content: a.buildAIContext(projectID, scenario, st.Locale)}} if len(history) > 20 { history = history[len(history)-20:] } for _, m := range history { msgs = append(msgs, ai.Message{Role: m.Role, Content: m.Content}) } msgs = append(msgs, ai.Message{Role: "user", Content: content}) if _, e = a.store.AddAIMessage(conv.ID, "user", content); e != nil { a.stopAIStream(conv.ID) return conv, e } a.store.TouchAIConversation(conv.ID, provider.Name()) go a.runAIStream(ctx, provider, conv.ID, msgs) return conv, nil } // runAIStream 消费 Provider 流并转发到前端,结束后落库助手回复。 func (a *App) runAIStream(ctx context.Context, provider ai.Provider, convID int64, msgs []ai.Message) { defer a.stopAIStream(convID) stream, e := provider.ChatStream(ctx, msgs) if e != nil { a.store.Log("error", "AI", "AI 请求失败", e.Error()) a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true}) return } var sb strings.Builder var usage *ai.Usage for chunk := range stream { if chunk.Usage != nil { usage = chunk.Usage } if chunk.Err != nil { // 已有部分内容则保留落库,方便用户继续。 if sb.Len() > 0 { if m, e2 := a.store.AddAIMessage(convID, "assistant", sb.String()); e2 == nil { a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true, MessageID: m.ID}) return } } a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: chunk.Err.Error(), Done: true}) return } if chunk.Content == "" { continue } sb.WriteString(chunk.Content) a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Delta: chunk.Content}) } if ctx.Err() != nil && sb.Len() == 0 { a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: "AI_CANCELLED", Done: true}) return } m, e := a.store.AddAIMessage(convID, "assistant", sb.String()) if e != nil { a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Error: e.Error(), Done: true}) return } if usage != nil { go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated) } a.store.TouchAIConversation(convID, provider.Name()) a.emit("ai:stream", aiStreamEvent{ConversationID: convID, Done: true, MessageID: m.ID}) } // ---------- 模块 AI 介绍(分析完成后异步生成,按项目+模块存最新一份) ---------- func (s *Store) GetAISummaries(projectID int64) ([]AISummary, error) { // projectID=0 表示工作台全局简报(今日规划/下班日报),存于 day_briefs。 q, args := `SELECT project_id,kind,content,provider,generated_at FROM ai_summaries WHERE project_id=?`, []any{projectID} if projectID == 0 { q, args = `SELECT 0,kind,content,provider,generated_at FROM day_briefs`, nil } rows, e := s.db.Query(q, args...) if e != nil { return nil, e } defer rows.Close() out := []AISummary{} for rows.Next() { var x AISummary if e = rows.Scan(&x.ProjectID, &x.Kind, &x.Content, &x.Provider, &x.GeneratedAt); e != nil { return nil, e } // 兜底清理历史坏数据:早期版本可能存入了带围栏的原始回答。 x.Content = stripFence(x.Content) out = append(out, x) } return out, rows.Err() } func (s *Store) SaveAISummary(projectID int64, kind, provider, content string) error { if projectID == 0 { _, e := s.db.Exec(`INSERT INTO day_briefs(kind,provider,content,generated_at) VALUES(?,?,?,?) ON CONFLICT(kind) DO UPDATE SET content=excluded.content,provider=excluded.provider,generated_at=excluded.generated_at`, kind, provider, content, nowRFC()) return e } _, e := s.db.Exec(`INSERT INTO ai_summaries(project_id,kind,provider,content,generated_at) VALUES(?,?,?,?,?) ON CONFLICT(project_id,kind) DO UPDATE SET content=excluded.content,provider=excluded.provider,generated_at=excluded.generated_at`, projectID, kind, provider, content, nowRFC()) return e } func (a *App) GetAISummaries(projectID int64) ([]AISummary, error) { if e := a.ready(); e != nil { return nil, e } return a.store.GetAISummaries(projectID) } // RegenerateAISummary 手动重新生成某个模块的 AI 介绍(后台执行,完成后发 ai:summary 事件)。 func (a *App) RegenerateAISummary(projectID int64, kind string) error { if e := a.ready(); e != nil { return e } if kind != "project" && kind != "git" && kind != "structure" && kind != "insights" && kind != "dayplan" && kind != "dayreport" && !scopeSummaryKinds[kind] { return errors.New("AI_SUMMARY_KIND_INVALID") } if _, e := a.aiProvider(); e != nil { return e } go a.generateAISummaries(projectID, kind) return nil } // aiProvider 按当前设置创建 Provider;未配置 Key 时返回 AI_NO_KEY。 func (a *App) aiProvider() (ai.Provider, error) { st, e := a.store.Settings() if e != nil { return nil, e } key := st.SparkKey if st.AIProvider == ai.ProviderDeepSeek { key = st.DeepSeekKey } return ai.New(st.AIProvider, key) } // aiSummarySem 全局串行生成,避免批量分析后并发打爆免费额度接口。 var aiSummarySem = make(chan struct{}, 1) // aiSummaryEvent 是 "ai:summary" 事件载荷。 type aiSummaryEvent struct { ProjectID int64 `json:"projectId"` Kind string `json:"kind"` Error string `json:"error,omitempty"` } // stripFence 去掉包裹全文的代码围栏(模型偶尔把整段回答包进 ```markdown 中, // 甚至只输出开头围栏忘了闭合——这种孤立围栏也要剥掉,否则整段被渲染成代码块)。 func stripFence(s string) string { if !strings.HasPrefix(s, "```") { return s } lines := strings.Split(s, "\n") last := len(lines) - 1 for last > 0 && strings.TrimSpace(lines[last]) == "" { last-- } if last >= 1 && strings.TrimSpace(lines[last]) == "```" { return strings.TrimSpace(strings.Join(lines[1:last], "\n")) } return strings.TrimSpace(strings.Join(lines[1:], "\n")) } // dedentCommon 去除各行公共的行首空白,避免模型输出整体缩进被 Markdown 误判为代码块。 func dedentCommon(s string) string { lines := strings.Split(s, "\n") common := -1 for _, ln := range lines { t := strings.TrimLeft(ln, " \t") if t == "" { continue } n := len(ln) - len(t) if common < 0 || n < common { common = n } if common == 0 { return s } } if common <= 0 { return s } for i, ln := range lines { if len(ln) >= common { lines[i] = ln[common:] } else { lines[i] = strings.TrimLeft(ln, " \t") } } return strings.Join(lines, "\n") } // summaryInstruction 摘要指令:与聊天场景共用数据上下文,但任务改为生成简介。 func summaryInstruction(kind, locale string) string { if s, ok := scopeInstruction(kind, locale); ok { return s } if locale == "en" { switch kind { case "git": return "Based on the data above, write a concise Git overview of this project (within 120 words): collaboration structure, activity trend and risky hotspots. Output Markdown text only, no headings." case "structure": return "Based on the data above, write a concise overview of this project's directory layout (within 120 words): module organization, size distribution and large-file risks. Output Markdown text only, no headings." case "insights": return "Based on the data above, write a concise health-check summary (within 120 words): health score, main issue types and what to fix first. Output Markdown text only, no headings." case "dayplan": return "Based on the todos and tickets above, plan my work day: sort by priority and due time, split into Morning and Afternoon, one line per item with a short reason (overdue, due today, high priority); end with one line about the biggest risk. Output a Markdown list, no big headings." case "dayreport": return "Based on today's activity above, write a first-person end-of-day report (one paragraph, 100-200 words, ready to paste into a work chat): what I completed, what is in progress, and what carries over to tomorrow. Output plain text, no headings or lists." } return "Based on the data above, write a concise project introduction (within 120 words): what it is, tech stack, scale and code health. Output Markdown text only, no headings." } switch kind { case "git": return "基于以上数据,用不超过 150 字写一段该项目的 Git 协作介绍:贡献结构、活跃趋势、高风险热点。直接输出 Markdown 正文,不要标题。" case "structure": return "基于以上数据,用不超过 150 字介绍该项目的目录结构与文件组织:模块划分、体积分布、大文件风险。直接输出 Markdown 正文,不要标题。" case "insights": return "基于以上数据,用不超过 150 字总结该项目的健康检查结果:健康分、主要问题类型与整改优先级。直接输出 Markdown 正文,不要标题。" case "dayplan": return "基于以上待办与工单,为我制定今天的工作规划:按优先级和截止时间排序,分【上午】【下午】两个时段,每项一行并注明理由(如已逾期、今天截止、高优先级);最后用一行提示最需要警惕的风险。只能引用上面列出的条目,不要编造任务。直接输出 Markdown 列表,不要大标题。" case "dayreport": return "基于以上今天的工作记录,用第一人称写一段 100~200 字的下班日报(一个自然段,适合直接粘贴到工作群):概述今天完成了哪些事项、推进中的事项,以及遗留或需要明天跟进的内容。只能引用上面列出的记录,不要编造。直接输出正文,不要标题和列表。" } return "基于以上数据,用不超过 150 字写一段项目介绍:项目定位、技术栈、代码规模与质量状况。直接输出 Markdown 正文,不要标题。" } // staticDayBrief 数据为空时的固定文案:避免模型在无数据时凭空编造任务。 func (a *App) staticDayBrief(kind, locale string) (string, bool) { switch kind { case "dayplan": todos, _ := a.store.ListTodos("all", 0) tickets, _ := a.store.ListTickets("all", 0) for _, x := range todos { if x.Status != "done" { return "", false } } for _, x := range tickets { if x.Status != "resolved" && x.Status != "closed" { return "", false } } if locale == "en" { return "No open todos or tickets today — enjoy the free time or plan something new.", true } return "今天没有待处理的待办或工单,可以安排一些新的计划,或者好好休息一下。", true case "dayreport": today := time.Now().Format("2006-01-02") todos, _ := a.store.ListTodos("all", 0) tickets, _ := a.store.ListTickets("all", 0) hasEvent := func(history string) bool { var list []histEntry if json.Unmarshal([]byte(history), &list) != nil { return false } for _, h := range list { if ts, e := time.Parse(time.RFC3339, h.At); e == nil && ts.Local().Format("2006-01-02") == today { return true } } return false } for _, x := range todos { if hasEvent(x.History) { return "", false } } for _, x := range tickets { if hasEvent(x.History) { return "", false } } if locale == "en" { return "No todo or ticket activity recorded today, so there is nothing to report yet.", true } return "今天还没有记录到待办或工单的状态变化,暂时没有可总结的内容。", true } return "", false } // generateAISummaries 依次为指定模块生成 AI 介绍并落库;未配置 Key 时静默跳过。 func (a *App) generateAISummaries(projectID int64, kinds ...string) { provider, e := a.aiProvider() if e != nil { return } if e := a.checkAIPolicy(); e != nil { return } st, _ := a.store.Settings() aiSummarySem <- struct{}{} defer func() { <-aiSummarySem }() for _, kind := range kinds { // 无数据的全局简报直接落固定文案,不浪费一次注定编造的模型调用。 if content, ok := a.staticDayBrief(kind, st.Locale); ok { if e := a.store.SaveAISummary(projectID, kind, "system", content); e == nil { a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind}) } continue } msgs := []ai.Message{ {Role: "system", Content: a.buildAIContext(projectID, kind, st.Locale)}, {Role: "user", Content: summaryInstruction(kind, st.Locale)}, } ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute) stream, se := provider.ChatStream(ctx, msgs) if se != nil { cancel() a.store.Log("warning", "AI", "AI 模块介绍生成失败", se.Error()) a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: se.Error()}) continue } var sb strings.Builder var streamErr error for chunk := range stream { if chunk.Err != nil { streamErr = chunk.Err break } sb.WriteString(chunk.Content) } cancel() if streamErr != nil || sb.Len() == 0 { msg := "empty response" if streamErr != nil { msg = streamErr.Error() } a.store.Log("warning", "AI", "AI 模块介绍生成失败", msg) a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind, Error: msg}) continue } if e := a.store.SaveAISummary(projectID, kind, provider.Name(), dedentCommon(stripFence(strings.TrimSpace(sb.String())))); e != nil { a.store.Log("warning", "AI", "AI 模块介绍保存失败", e.Error()) continue } a.store.Log("info", "AI", "AI 模块介绍已更新", fmt.Sprintf("project=%d kind=%s", projectID, kind)) a.emit("ai:summary", aiSummaryEvent{ProjectID: projectID, Kind: kind}) } } // ---------- 场景化提示词 ---------- // dayEvent 是今天发生的一次状态变化(用于下班日报)。 type dayEvent struct { kind, title, project, status, at string } // writeDayContext 聚合“今天”的待办/工单数据(dayplan 看待处理项,dayreport 看今天的轨迹)。 func (a *App) writeDayContext(b *strings.Builder, scenario string) { now := time.Now() weekdays := []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} today := now.Format("2006-01-02") fmt.Fprintf(b, "\n## 今天\n- 日期:%s(%s),现在时间 %s\n", today, weekdays[int(now.Weekday())], now.Format("15:04")) todos, _ := a.store.ListTodos("all", 0) tickets, _ := a.store.ListTickets("all", 0) if scenario == "dayplan" { b.WriteString("\n## 待处理的待办\n") n := 0 for _, x := range todos { if x.Status == "done" || n >= 25 { continue } n++ fmt.Fprintf(b, "- [%s|%s] %s(截止 %s,项目 %s)%s\n", x.Status, x.Priority, x.Title, orDash(x.DueAt), orDash(x.ProjectName), dueFlag(x.DueAt, today)) } if n == 0 { b.WriteString("- 无\n") } b.WriteString("\n## 待处理的工单\n") n = 0 for _, x := range tickets { if x.Status == "resolved" || x.Status == "closed" || n >= 25 { continue } n++ fmt.Fprintf(b, "- [%s|%s] %s(截止 %s,项目 %s)%s\n", x.Status, x.Priority, x.Title, orDash(x.DueAt), orDash(x.ProjectName), dueFlag(x.DueAt, today)) } if n == 0 { b.WriteString("- 无\n") } return } // dayreport:从生命周期轨迹里筛出今天发生的状态变化 var events []dayEvent collect := func(kind, title, project, history string) { var list []histEntry if json.Unmarshal([]byte(history), &list) != nil { return } for _, h := range list { ts, e := time.Parse(time.RFC3339, h.At) if e != nil || ts.Local().Format("2006-01-02") != today { continue } events = append(events, dayEvent{kind: kind, title: title, project: project, status: h.Status, at: ts.Local().Format("15:04")}) } } for _, x := range todos { collect("待办", x.Title, x.ProjectName, x.History) } for _, x := range tickets { collect("工单", x.Title, x.ProjectName, x.History) } b.WriteString("\n## 今天的工作记录(按状态变化)\n") if len(events) == 0 { b.WriteString("- 今天没有记录到状态变化\n") } for i, ev := range events { if i >= 40 { break } fmt.Fprintf(b, "- %s %s「%s」→ %s(项目 %s)\n", ev.at, ev.kind, ev.title, ev.status, orDash(ev.project)) } b.WriteString("\n状态说明:open=新建/待开始,doing/in_progress=开始处理,done/resolved=完成,closed=关闭。\n") } func orDash(s string) string { if strings.TrimSpace(s) == "" { return "无" } return s } // dueFlag 给截止时间补充“已逾期/今天截止”标记,帮助模型排优先级。 func dueFlag(due, today string) string { if due == "" { return "" } d := due if len(d) > 10 { d = d[:10] } switch { case d < today: return "【已逾期】" case d == today: return "【今天截止】" } return "" } // buildAIContext 按场景聚合项目数据生成系统提示词。 func (a *App) buildAIContext(projectID int64, scenario, locale string) string { var b strings.Builder if locale == "en" { b.WriteString("You are the built-in AI analyst of 年糕崽崽 PMS, a local code statistics and project workbench app. Answer in English, be specific and actionable, use Markdown.\n") } else { b.WriteString("你是代码统计与项目工作台应用「年糕崽崽 PMS」的内置 AI 分析师。请用中文回答,结论要具体、可执行,使用 Markdown 排版。\n") } // 工作台全局场景:不依赖具体项目,聚合今天的待办与工单。 if scenario == "dayplan" || scenario == "dayreport" { a.writeDayContext(&b, scenario) return b.String() } // 页面级全局总结:启动台 / 日志 / 日历 / 配置 / 笔记。 if scopeSummaryKinds[scenario] { a.writeScopeContext(&b, scenario) return b.String() } if projectID <= 0 { return b.String() } p, e := a.store.GetProject(projectID) if e != nil || p.ID == 0 { return b.String() } fmt.Fprintf(&b, "\n## 项目基础信息\n- 名称: %s\n- 路径: %s\n- 描述: %s\n- 代码行: %d(代码 %d / 注释 %d / 空行 %d)\n- 文件数: %d\n- 提交数: %d,贡献者: %d\n- 最近分析: %s\n", p.Name, p.Path, p.Description, p.Stats.TotalLines, p.Stats.CodeLines, p.Stats.CommentLines, p.Stats.BlankLines, p.Stats.FileCount, p.Stats.CommitCount, p.Stats.ContributorCount, p.Stats.LastAnalyzed) if len(p.Languages) > 0 { b.WriteString("\n## 语言分布\n") for i, l := range p.Languages { if i >= 12 { break } fmt.Fprintf(&b, "- %s: %d 文件, 代码 %d 行, 注释 %d 行\n", l.Name, l.Files, l.Code, l.Comments) } } switch scenario { case "project": if st, e := a.store.Structure(projectID); e == nil { b.WriteString("\n## 目录结构概览\n") for i, f := range st.Folders { if i >= 10 { break } fmt.Fprintf(&b, "- 目录 %s: %d 文件, %d 字节\n", f.Name, f.Files, f.Size) } for i, f := range st.LargeFiles { if i >= 8 { break } fmt.Fprintf(&b, "- 大文件 %s: %d 字节\n", f.Path, f.Size) } } if ins, e := a.store.Insights(projectID); e == nil && len(ins.Issues) > 0 { fmt.Fprintf(&b, "\n## 静态洞察(健康分 %d)\n", ins.HealthScore) for i, x := range ins.Issues { if i >= 12 { break } fmt.Fprintf(&b, "- [%s] %s:%s(%s)\n", x.Severity, x.Title, x.Detail, x.Path) } } b.WriteString("\n任务:基于以上数据分析该项目的技术栈、代码规模与质量风险,并给出改进建议。\n") case "git": if g, e := a.store.GitStats(projectID); e == nil { fmt.Fprintf(&b, "\n## Git 概览\n- 分支: %s,提交 %d,+%d/-%d 行\n", g.CurrentBranch, g.CommitCount, g.Added, g.Deleted) b.WriteString("\n## 贡献者\n") for i, c := range g.Contributors { if i >= 10 { break } fmt.Fprintf(&b, "- %s: %d 次提交, +%d/-%d\n", c.Name, c.Commits, c.Added, c.Deleted) } b.WriteString("\n## 最近提交\n") for i, c := range g.Commits { if i >= 15 { break } fmt.Fprintf(&b, "- %s %s(%s)\n", c.Date, c.Message, c.Author) } if len(g.Hotspots) > 0 { b.WriteString("\n## 高频变更文件\n") for i, h := range g.Hotspots { if i >= 10 { break } fmt.Fprintf(&b, "- %s: %d 次变更, +%d/-%d\n", h.Path, h.Changes, h.Added, h.Deleted) } } } b.WriteString("\n任务:分析该项目的 Git 贡献情况:贡献结构是否健康、活跃度趋势、高风险热点文件,并给出协作改进建议。\n") case "structure": if st, e := a.store.Structure(projectID); e == nil { fmt.Fprintf(&b, "\n## 目录结构\n- 总文件 %d,总目录 %d,总大小 %d 字节\n", st.TotalFiles, st.TotalDirs, st.TotalSize) for i, f := range st.Folders { if i >= 14 { break } fmt.Fprintf(&b, "- 目录 %s: %d 文件, %d 字节\n", f.Name, f.Files, f.Size) } if len(st.LargeFiles) > 0 { b.WriteString("\n## 大文件\n") for i, f := range st.LargeFiles { if i >= 10 { break } fmt.Fprintf(&b, "- %s: %d 字节\n", f.Path, f.Size) } } if len(st.Extensions) > 0 { b.WriteString("\n## 扩展名分布\n") for i, x := range st.Extensions { if i >= 12 { break } fmt.Fprintf(&b, "- %s: %d 文件, %d 字节\n", x.Extension, x.Files, x.Size) } } } b.WriteString("\n任务:分析该项目的目录组织与模块划分是否合理,指出体积集中点与大文件风险,并给出精简建议。\n") case "insights": ins, e := a.store.Insights(projectID) if e != nil { // 从未做过检查时现算一次,避免 AI 在无数据下臆造 ins, e = a.RefreshProjectInsights(projectID) } if e == nil { fmt.Fprintf(&b, "\n## 静态检查(健康分 %d)\n- 高危 %d / 中危 %d / 低危 %d,TODO 标记 %d,超长文件 %d,大文件 %d\n", ins.HealthScore, ins.Summary.High, ins.Summary.Medium, ins.Summary.Low, ins.Summary.TodoCount, ins.Summary.LongFiles, ins.Summary.LargeFiles) for i, x := range ins.Issues { if i >= 20 { break } fmt.Fprintf(&b, "- [%s|%s] %s:%s(%s)建议:%s\n", x.Severity, x.Type, x.Title, x.Detail, x.Path, x.Suggestion) } } b.WriteString("\n任务:解读这些检查结果:整体健康状况、最需要优先处理的问题类别与整改顺序建议。\n") case "todo": todos, _ := a.store.ListTodos("all", projectID) b.WriteString("\n## 项目待办\n") if len(todos) == 0 { b.WriteString("(暂无待办)\n") } for i, t := range todos { if i >= 30 { break } fmt.Fprintf(&b, "- [%s|%s] %s(截止 %s)%s\n", t.Status, t.Priority, t.Title, t.DueAt, t.Content) } b.WriteString("\n任务:分析这些待办的优先级排布是否合理,识别逾期风险,给出本周执行顺序建议。\n") case "ticket": tickets, _ := a.store.ListTickets("all", projectID) b.WriteString("\n## 项目工单\n") if len(tickets) == 0 { b.WriteString("(暂无工单)\n") } for i, t := range tickets { if i >= 30 { break } fmt.Fprintf(&b, "- [%s|%s|%s] %s(%s ~ %s)%s\n", t.Type, t.Status, t.Priority, t.Title, t.StartAt, t.DueAt, t.Description) } b.WriteString("\n任务:从需求管理角度分析这些工单:排期是否合理、类型分布、阻塞风险,并给出处理顺序与拆解建议。\n") } return b.String() }