Files
code-utils/ai.go

820 lines
28 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
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
}
2026-08-15 17:18:00 +08:00
if e := a.checkAIPolicy(); e != nil {
return AIConversation{}, e
}
2026-08-14 07:52:01 +08:00
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
2026-08-15 17:18:00 +08:00
var usage *ai.Usage
2026-08-14 07:52:01 +08:00
for chunk := range stream {
2026-08-15 17:18:00 +08:00
if chunk.Usage != nil {
usage = chunk.Usage
}
2026-08-14 07:52:01 +08:00
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
}
2026-08-15 17:18:00 +08:00
if chunk.Content == "" {
continue
}
2026-08-14 07:52:01 +08:00
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
}
2026-08-15 17:18:00 +08:00
if usage != nil {
go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated)
}
2026-08-14 07:52:01 +08:00
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
}
2026-08-15 17:18:00 +08:00
if e := a.checkAIPolicy(); e != nil {
return
}
2026-08-14 07:52:01 +08:00
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 / 低危 %dTODO 标记 %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()
}