Files
code-utils/ai_tasks.go
李琦 c430a0f6da 1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。
2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。

3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。

4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。

5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。

6. 项目云同步与认领

全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。
统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。
个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。
途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00

174 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:MMticket: 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
}
msgs := []ai.Message{
{Role: "system", Content: aiTaskPrompt(kind)},
{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)
}
if usage != nil {
go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated)
}
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
}
func aiTaskPrompt(kind string) string {
now := time.Now()
weekdays := []string{"日", "一", "二", "三", "四", "五", "六"}
head := fmt.Sprintf("今天是 %s星期%s当前时间 %s。\n", now.Format("2006-01-02"), weekdays[int(now.Weekday())], now.Format("15:04"))
if kind == "ticket" {
return head + `你是研发工单拆解助手。把用户的一句话拆成 1~10 条工单。
只输出 JSON 数组禁止任何解释、markdown 围栏或多余文字。每个元素:
{"title":"简短标题(<=40字)","description":"补充细节,可为空","type":"feature|bug|task|improvement","priority":"low|medium|high","startAt":"YYYY-MM-DD","dueAt":"YYYY-MM-DD"}
规则标题用与用户输入相同的语言startAt 默认今天dueAt 不得早于 startAt用户未提及工期时按任务量合理估算1~7 天);能从用户话中推断出的日期(如“周五前”“下周”)必须转换为具体日期。`
}
return head + `你是待办事项拆解助手。把用户的一句话拆成 1~10 条待办。
只输出 JSON 数组禁止任何解释、markdown 围栏或多余文字。每个元素:
{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM 或空字符串"}
规则:标题用与用户输入相同的语言;只有用户明确或可合理推断截止时间时才填 dueAt如“明天下午”→ 明天 18: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 = ""
if !validDateStr(d.StartAt) {
d.StartAt = today
}
if !validDateStr(d.DueAt) {
d.DueAt = time.Now().AddDate(0, 0, 3).Format("2006-01-02")
}
if d.DueAt < d.StartAt {
d.DueAt = d.StartAt
}
} else {
d.Type, d.Description, d.StartAt = "", "", ""
d.DueAt = normalizeTodoDue(d.DueAt)
}
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 {
_, e := time.Parse("2006-01-02", strings.TrimSpace(s))
return e == nil
}
// normalizeTodoDue 接受 YYYY-MM-DDTHH:MM 或纯日期(补 18:00其余返回空。
func normalizeTodoDue(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if _, e := time.Parse("2006-01-02T15:04", s); e == nil {
return s
}
if validDateStr(s) {
return s + "T18:00"
}
return ""
}