2026-08-17 09:04:53 +08:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
// ai_tasks.go 一句话生成多条待办/工单:调用 AI 把自然语言拆解成结构化草稿,
|
|
|
|
|
|
// 前端预览确认后再逐条走 SaveTodo / SaveTicket 正常落库。
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"view/service/ai"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// AITaskDraft 是 AI 生成的一条草稿(todo 与 ticket 共用,前端按 kind 取字段)。
|
|
|
|
|
|
type AITaskDraft struct {
|
|
|
|
|
|
Kind string `json:"kind"` // todo | ticket
|
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
|
Content string `json:"content"` // todo 备注
|
|
|
|
|
|
Description string `json:"description"` // ticket 描述
|
|
|
|
|
|
Type string `json:"type"` // ticket: feature | bug | task | improvement
|
|
|
|
|
|
Priority string `json:"priority"` // low | medium | high
|
|
|
|
|
|
StartAt string `json:"startAt"` // ticket: YYYY-MM-DD
|
|
|
|
|
|
DueAt string `json:"dueAt"` // todo: YYYY-MM-DDTHH:MM;ticket: YYYY-MM-DD
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AIGenerateTasks 把一句话拆解成多条待办或工单草稿(不落库,由前端确认后保存)。
|
|
|
|
|
|
func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
if kind != "todo" && kind != "ticket" {
|
|
|
|
|
|
return nil, errors.New("BAD_REQUEST")
|
|
|
|
|
|
}
|
|
|
|
|
|
text = strings.TrimSpace(text)
|
|
|
|
|
|
if text == "" {
|
|
|
|
|
|
return nil, errors.New("AI_EMPTY_MESSAGE")
|
|
|
|
|
|
}
|
|
|
|
|
|
provider, e := a.aiProvider()
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
if e := a.checkAIPolicy(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
locale := "zh-CN"
|
|
|
|
|
|
if st, se := a.store.Settings(); se == nil && st.Locale != "" {
|
|
|
|
|
|
locale = st.Locale
|
|
|
|
|
|
}
|
2026-08-17 09:04:53 +08:00
|
|
|
|
msgs := []ai.Message{
|
2026-08-17 14:00:47 +08:00
|
|
|
|
{Role: "system", Content: aiTaskPrompt(kind, locale)},
|
2026-08-17 09:04:53 +08:00
|
|
|
|
{Role: "user", Content: text},
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
stream, e := provider.ChatStream(ctx, msgs)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.store.Log("error", "AI", "AI 任务生成请求失败", e.Error())
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
|
var usage *ai.Usage
|
|
|
|
|
|
for chunk := range stream {
|
|
|
|
|
|
if chunk.Usage != nil {
|
|
|
|
|
|
usage = chunk.Usage
|
|
|
|
|
|
}
|
|
|
|
|
|
if chunk.Err != nil {
|
|
|
|
|
|
a.store.Log("error", "AI", "AI 任务生成失败", chunk.Err.Error())
|
|
|
|
|
|
return nil, chunk.Err
|
|
|
|
|
|
}
|
|
|
|
|
|
sb.WriteString(chunk.Content)
|
|
|
|
|
|
}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
a.recordAIUsage(provider.Name(), "generate:"+kind, usage, msgs, sb.String())
|
2026-08-17 09:04:53 +08:00
|
|
|
|
drafts, pe := parseAITaskDrafts(kind, sb.String())
|
|
|
|
|
|
if pe != nil {
|
|
|
|
|
|
a.store.Log("warning", "AI", "AI 任务生成解析失败", sb.String())
|
|
|
|
|
|
return nil, pe
|
|
|
|
|
|
}
|
|
|
|
|
|
a.store.Log("info", "AI", "AI 任务生成完成", fmt.Sprintf("kind=%s count=%d", kind, len(drafts)))
|
|
|
|
|
|
return drafts, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 14:00:47 +08:00
|
|
|
|
func aiTaskPrompt(kind, locale string) string {
|
|
|
|
|
|
head := aiCalendarContext(locale)
|
2026-08-17 09:04:53 +08:00
|
|
|
|
if kind == "ticket" {
|
2026-08-17 14:00:47 +08:00
|
|
|
|
return head + `你是研发工单拆解助手。把用户输入拆成 1~10 条工单。
|
2026-08-17 09:04:53 +08:00
|
|
|
|
只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素:
|
|
|
|
|
|
{"title":"简短标题(<=40字)","description":"补充细节,可为空","type":"feature|bug|task|improvement","priority":"low|medium|high","startAt":"YYYY-MM-DD","dueAt":"YYYY-MM-DD"}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
日期规则(必须遵守):
|
|
|
|
|
|
1. 按用户输入的语言理解相对日期(中文:今天/明天/后天/大后天/本周X/下周X/这周五/下周一/月底/下个月;英文:today/tomorrow/this Friday/next Monday/end of week 等),对照上面日历换成具体 YYYY-MM-DD。
|
|
|
|
|
|
2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 startAt/dueAt 都落在那个时间范围内,不要一律填今天。
|
|
|
|
|
|
3. 一句话里提到多天(如“周三做A,周五做B”“这周每天…”),每条工单各自写对应的 startAt 和 dueAt,不要全部同一天。
|
|
|
|
|
|
4. 每条都必须写 dueAt。用户给了截止就用;只给了开始日则按工作量估 1~5 天;完全没提日期则 startAt=今天、dueAt 按工作量估。
|
|
|
|
|
|
5. dueAt 不得早于 startAt。标题语言与用户输入一致。
|
|
|
|
|
|
6. 用户提到节假日/黄金周/放假/调休(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体 YYYY-MM-DD,禁止自己换算农历。「X前」= 节日前一天;「X期间」= 表中放假区间(没有则用当天);「X后」= 假期结束后一天。`
|
2026-08-17 09:04:53 +08:00
|
|
|
|
}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
return head + `你是待办事项拆解助手。把用户输入拆成 1~10 条待办。
|
2026-08-17 09:04:53 +08:00
|
|
|
|
只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素:
|
2026-08-17 14:00:47 +08:00
|
|
|
|
{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM"}
|
|
|
|
|
|
日期规则(必须遵守):
|
|
|
|
|
|
1. 按用户输入的语言理解相对日期和时间(中文:今天/明天/后天/本周X/下周X/上午/下午/晚上/点钟;英文:today/tomorrow/this Friday/next week/afternoon 等),对照上面日历换成具体 YYYY-MM-DDTHH:MM。
|
|
|
|
|
|
2. 用户说的不一定是今天:若整段话指向某一天或某一周,所有条目的 dueAt 都落在那个时间,不要一律填今天。
|
|
|
|
|
|
3. 一句话里提到多天(如“周一买菜,周三交报告”),每条待办各自写对应的 dueAt。
|
|
|
|
|
|
4. 每条都必须写 dueAt,不要留空。用户给了时刻就用;只给了日期则:上午→10:00、下午→18:00、晚上→21:00、未提时段→18:00。完全没提日期则按紧急度估:高优今天 18:00,中优明天 18:00,低优 3 天后 18:00。
|
|
|
|
|
|
5. 标题语言与用户输入一致。
|
|
|
|
|
|
6. 用户提到节假日/黄金周/放假(国庆、十一、春节、过年、中秋、端午、元旦、五一、清明、圣诞、感恩节等),必须对照上面节假日表换成具体日期,禁止自己换算农历。「X前」= 节日前一天 18:00;「X期间」落在放假区间;「X后」从假期结束后一天起。`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// aiCalendarContext 给模型一份「今天 + 本周/下周对照表」,避免相对日期猜错,也不默认全是今天。
|
|
|
|
|
|
func aiCalendarContext(locale string) string {
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
en := strings.HasPrefix(strings.ToLower(locale), "en")
|
|
|
|
|
|
wdCN := []string{"日", "一", "二", "三", "四", "五", "六"}
|
|
|
|
|
|
wdEN := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
|
|
|
|
|
weekday := func(t time.Time) string {
|
|
|
|
|
|
if en {
|
|
|
|
|
|
return wdEN[int(t.Weekday())]
|
|
|
|
|
|
}
|
|
|
|
|
|
return "星期" + wdCN[int(t.Weekday())]
|
|
|
|
|
|
}
|
|
|
|
|
|
// 本周一:Go Weekday 周日=0
|
|
|
|
|
|
offset := int(now.Weekday())
|
|
|
|
|
|
if offset == 0 {
|
|
|
|
|
|
offset = 7
|
|
|
|
|
|
}
|
|
|
|
|
|
monday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1-offset)
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
|
if en {
|
|
|
|
|
|
fmt.Fprintf(&b, "Today is %s (%s), current time %s. Timezone is the user's local time.\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04"))
|
|
|
|
|
|
b.WriteString("This week:\n")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
fmt.Fprintf(&b, "今天是 %s(%s),当前时间 %s。时区按用户本机。\n", now.Format("2006-01-02"), weekday(now), now.Format("15:04"))
|
|
|
|
|
|
b.WriteString("本周对照:\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := 0; i < 7; i++ {
|
|
|
|
|
|
d := monday.AddDate(0, 0, i)
|
|
|
|
|
|
mark := ""
|
|
|
|
|
|
if d.Format("2006-01-02") == now.Format("2006-01-02") {
|
|
|
|
|
|
if en {
|
|
|
|
|
|
mark = " ← today"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
mark = " ← 今天"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(&b, "- %s %s%s\n", weekday(d), d.Format("2006-01-02"), mark)
|
|
|
|
|
|
}
|
|
|
|
|
|
if en {
|
|
|
|
|
|
b.WriteString("Next week:\n")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
b.WriteString("下周对照:\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := 0; i < 7; i++ {
|
|
|
|
|
|
d := monday.AddDate(0, 0, 7+i)
|
|
|
|
|
|
fmt.Fprintf(&b, "- %s %s\n", weekday(d), d.Format("2006-01-02"))
|
|
|
|
|
|
}
|
|
|
|
|
|
monthEnd := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location())
|
|
|
|
|
|
nextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location())
|
|
|
|
|
|
if en {
|
|
|
|
|
|
fmt.Fprintf(&b, "End of this month: %s. First day of next month: %s.\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02"))
|
|
|
|
|
|
b.WriteString("Convert relative phrases using this calendar. Do not default every item to today unless the user said today or gave no date at all.\n")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
fmt.Fprintf(&b, "本月最后一天:%s。下月 1 日:%s。\n", monthEnd.Format("2006-01-02"), nextMonth.Format("2006-01-02"))
|
|
|
|
|
|
b.WriteString("相对日期必须对照上面日历换算。用户没说今天时,不要把每条都填成今天。\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
b.WriteString(formatHolidayBlock(now, en))
|
|
|
|
|
|
return b.String()
|
2026-08-17 09:04:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// parseAITaskDrafts 从模型输出中提取 JSON 数组并规范化字段。
|
|
|
|
|
|
func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) {
|
|
|
|
|
|
raw = stripFence(strings.TrimSpace(raw))
|
|
|
|
|
|
start, end := strings.Index(raw, "["), strings.LastIndex(raw, "]")
|
|
|
|
|
|
if start < 0 || end <= start {
|
|
|
|
|
|
return nil, errors.New("AI_BAD_RESPONSE")
|
|
|
|
|
|
}
|
|
|
|
|
|
var list []AITaskDraft
|
|
|
|
|
|
if json.Unmarshal([]byte(raw[start:end+1]), &list) != nil {
|
|
|
|
|
|
return nil, errors.New("AI_BAD_RESPONSE")
|
|
|
|
|
|
}
|
|
|
|
|
|
today := time.Now().Format("2006-01-02")
|
|
|
|
|
|
out := make([]AITaskDraft, 0, len(list))
|
|
|
|
|
|
for _, d := range list {
|
|
|
|
|
|
d.Kind = kind
|
|
|
|
|
|
d.Title = strings.TrimSpace(d.Title)
|
|
|
|
|
|
if d.Title == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if r := []rune(d.Title); len(r) > 120 {
|
|
|
|
|
|
d.Title = string(r[:120])
|
|
|
|
|
|
}
|
|
|
|
|
|
if d.Priority != "low" && d.Priority != "medium" && d.Priority != "high" {
|
|
|
|
|
|
d.Priority = "medium"
|
|
|
|
|
|
}
|
|
|
|
|
|
if kind == "ticket" {
|
|
|
|
|
|
switch d.Type {
|
|
|
|
|
|
case "feature", "bug", "task", "improvement":
|
|
|
|
|
|
default:
|
|
|
|
|
|
d.Type = "task"
|
|
|
|
|
|
}
|
|
|
|
|
|
d.Content = ""
|
2026-08-17 14:00:47 +08:00
|
|
|
|
// 优先保留模型按用户语义写出的日期;缺了才补,避免把「下周三」整批改成今天。
|
|
|
|
|
|
d.StartAt = dateOnly(d.StartAt)
|
|
|
|
|
|
d.DueAt = dateOnly(d.DueAt)
|
|
|
|
|
|
if d.StartAt == "" && d.DueAt != "" {
|
|
|
|
|
|
d.StartAt = d.DueAt
|
|
|
|
|
|
}
|
|
|
|
|
|
if d.StartAt == "" {
|
2026-08-17 09:04:53 +08:00
|
|
|
|
d.StartAt = today
|
|
|
|
|
|
}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
if d.DueAt == "" {
|
|
|
|
|
|
if t, e := time.Parse("2006-01-02", d.StartAt); e == nil {
|
|
|
|
|
|
d.DueAt = t.AddDate(0, 0, 2).Format("2006-01-02")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
d.DueAt = time.Now().AddDate(0, 0, 2).Format("2006-01-02")
|
|
|
|
|
|
}
|
2026-08-17 09:04:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
if d.DueAt < d.StartAt {
|
|
|
|
|
|
d.DueAt = d.StartAt
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
d.Type, d.Description, d.StartAt = "", "", ""
|
|
|
|
|
|
d.DueAt = normalizeTodoDue(d.DueAt)
|
2026-08-17 14:00:47 +08:00
|
|
|
|
if d.DueAt == "" {
|
|
|
|
|
|
// 待办也必须有截止日期,缺省按优先级估。
|
|
|
|
|
|
days := 1
|
|
|
|
|
|
if d.Priority == "high" {
|
|
|
|
|
|
days = 0
|
|
|
|
|
|
} else if d.Priority == "low" {
|
|
|
|
|
|
days = 3
|
|
|
|
|
|
}
|
|
|
|
|
|
d.DueAt = time.Now().AddDate(0, 0, days).Format("2006-01-02") + "T18:00"
|
|
|
|
|
|
}
|
2026-08-17 09:04:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
out = append(out, d)
|
|
|
|
|
|
if len(out) >= 20 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(out) == 0 {
|
|
|
|
|
|
return nil, errors.New("AI_EMPTY_RESULT")
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func validDateStr(s string) bool {
|
2026-08-17 14:00:47 +08:00
|
|
|
|
return dateOnly(s) != ""
|
2026-08-17 09:04:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 14:00:47 +08:00
|
|
|
|
// dateOnly 从 YYYY-MM-DD 或带时间的写法里抽出日期;解析失败返回空。
|
|
|
|
|
|
func dateOnly(s string) string {
|
2026-08-17 09:04:53 +08:00
|
|
|
|
s = strings.TrimSpace(s)
|
|
|
|
|
|
if s == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
if i := strings.IndexAny(s, "T "); i > 0 {
|
|
|
|
|
|
s = s[:i]
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, e := time.Parse("2006-01-02", s); e == nil {
|
2026-08-17 09:04:53 +08:00
|
|
|
|
return s
|
|
|
|
|
|
}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// normalizeTodoDue 接受常见日期/时间写法,统一成 datetime-local 用的 YYYY-MM-DDTHH:MM。
|
|
|
|
|
|
func normalizeTodoDue(s string) string {
|
|
|
|
|
|
s = strings.TrimSpace(s)
|
|
|
|
|
|
if s == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
s = strings.ReplaceAll(s, " ", "T")
|
|
|
|
|
|
for _, layout := range []string{
|
|
|
|
|
|
"2006-01-02T15:04",
|
|
|
|
|
|
"2006-01-02T15:04:05",
|
|
|
|
|
|
time.RFC3339,
|
|
|
|
|
|
} {
|
|
|
|
|
|
if t, e := time.Parse(layout, s); e == nil {
|
|
|
|
|
|
return t.Format("2006-01-02T15:04")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 09:04:53 +08:00
|
|
|
|
if validDateStr(s) {
|
|
|
|
|
|
return s + "T18:00"
|
|
|
|
|
|
}
|
2026-08-17 14:00:47 +08:00
|
|
|
|
if i := strings.Index(s, "T"); i == 10 && validDateStr(s[:10]) {
|
|
|
|
|
|
return s[:10] + "T18:00"
|
|
|
|
|
|
}
|
2026-08-17 09:04:53 +08:00
|
|
|
|
return ""
|
|
|
|
|
|
}
|