804 lines
28 KiB
Go
804 lines
28 KiB
Go
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
|
||
}
|
||
|
||
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
|
||
for chunk := range stream {
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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()
|
||
}
|