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 / 低危 %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 ( )
}