Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 20 additions & 109 deletions cmd/migrate/main.go
Original file line number Diff line number Diff line change
@@ -1,134 +1,45 @@
// Command migrate 执行数据库 schema 迁移(用 GORM AutoMigrate)。
// 用法: go run ./cmd/migrate
package main

import (
"context"
"database/sql"
"flag"
"fmt"
"os"
"strings"
"time"
"log"

"solvify-agent/internal/model/entity"
"solvify-agent/pkg/config"
"solvify-agent/pkg/database"
"solvify-agent/pkg/logger"
)

func main() {
var (
configPath = flag.String("config", "configs/config.yaml", "配置文件路径")
dryRun = flag.Bool("dry-run", false, "仅打印要执行的 SQL,不真正运行")
)
flag.Parse()
_ = logger.InitDefault()

files := flag.Args()
if len(files) == 0 {
fmt.Println("用法: go run cmd/migrate/main.go [-config=...] [-dry-run] <sql 文件1> [sql 文件2] ...")
os.Exit(1)
}

// 加载配置
cfg, err := config.Load(*configPath)
cfg, err := config.Load("configs/config.yaml")
if err != nil {
fmt.Printf("加载配置失败: %v\n", err)
os.Exit(1)
log.Fatalf("加载配置失败: %v", err)
}

// 初始化日志
logger.Init(&cfg.Log)

// 连接 PostgreSQL
db, err := database.OpenPostgreSQL(&cfg.Database.Postgres)
if err != nil {
fmt.Printf("连接数据库失败: %v\n", err)
os.Exit(1)
}
defer func() { _ = database.ClosePostgreSQL(db) }()

sqlDB, err := db.DB()
if err != nil {
fmt.Printf("获取连接池失败: %v\n", err)
os.Exit(1)
log.Fatalf("连接 PostgreSQL 失败: %v", err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
fmt.Println("开始迁移...")

for _, file := range files {
sql, err := os.ReadFile(file)
if err != nil {
fmt.Printf("读取 SQL 文件失败 %s: %v\n", file, err)
os.Exit(1)
}

if *dryRun {
fmt.Printf("\n--- %s (dry-run) ---\n%s\n", file, string(sql))
continue
}

fmt.Printf("正在执行: %s\n", file)
if isSelectQuery(string(sql)) {
if err := queryAndPrint(ctx, sqlDB, string(sql)); err != nil {
fmt.Printf("查询失败 %s: %v\n", file, err)
os.Exit(1)
}
} else {
if _, err := sqlDB.ExecContext(ctx, string(sql)); err != nil {
fmt.Printf("执行 SQL 失败 %s: %v\n", file, err)
os.Exit(1)
}
}
fmt.Printf("完成: %s\n", file)
// 迁移 ChatSession(自动补 pending_clarify / pending_checkpoint 列)
if err := db.AutoMigrate(&entity.ChatSession{}); err != nil {
log.Fatalf("迁移 ChatSession 失败: %v", err)
}
fmt.Println("✓ chat_sessions 已就绪")

fmt.Println("\n所有 SQL 脚本执行完成")
}

func isSelectQuery(sql string) bool {
trimmed := strings.TrimSpace(sql)
// 跳过单行注释,找到第一个有效 token
for strings.HasPrefix(trimmed, "--") {
idx := strings.Index(trimmed, "\n")
if idx < 0 {
return false
}
trimmed = strings.TrimSpace(trimmed[idx+1:])
// 创建 agent_checkpoints 表
if err := db.AutoMigrate(&entity.AgentCheckpoint{}); err != nil {
log.Fatalf("迁移 AgentCheckpoint 失败: %v", err)
}
return strings.HasPrefix(strings.ToUpper(trimmed), "SELECT")
}
fmt.Println("✓ agent_checkpoints 已就绪")

func queryAndPrint(ctx context.Context, db *sql.DB, query string) error {
rows, err := db.QueryContext(ctx, query)
if err != nil {
return err
}
defer rows.Close()

columns, err := rows.Columns()
if err != nil {
return err
}

fmt.Println(strings.Join(columns, " | "))
fmt.Println(strings.Repeat("-", 60))

values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}

for rows.Next() {
if err := rows.Scan(valuePtrs...); err != nil {
return err
}
for i, v := range values {
if i > 0 {
fmt.Print(" | ")
}
fmt.Printf("%v", v)
}
fmt.Println()
}
return rows.Err()
fmt.Println("迁移完成 ✅")
}
146 changes: 124 additions & 22 deletions design/vue/src/composables/useChat.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ref, computed, nextTick, inject } from 'vue'
import { ref, computed, nextTick, inject, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { marked } from 'marked'
import * as chatApi from '@/api/chat'
import * as modelApi from '@/api/model'
import * as authApi from '@/api/auth'
import { request } from '@/api/client'
import type { ChatSession, FeedbackRequest } from '@/types/chat'
import type { ChatSession, FeedbackRequest, PendingApproval } from '@/types/chat'
import type { StreamEvent } from '@/types/chat'

// ── UI 展示用的本地类型 ──
Expand Down Expand Up @@ -105,6 +105,11 @@ export function useChat() {
// ── 中断控制 ──
let abortController: AbortController | null = null

// ── 审批状态(危险工具中断) ──
const pendingApproval = ref<PendingApproval | null>(null)
// interrupt 事件所在的 assistant 消息块 ID,恢复流程复用同一个
let interruptedAssistantId = ''

// ── 计算属性 ──
const activeSession = computed(() =>
sessions.value.find((s) => s.id === activeSessionId.value),
Expand Down Expand Up @@ -241,8 +246,28 @@ export function useChat() {
loadMessages(sessionId)
}

// 切换会话时恢复/清除审批卡状态
watch(
() => activeSession.value,
(sess) => {
const pc = sess?.pending_checkpoint
if (pc && pc.checkpoint_id) {
pendingApproval.value = {
checkpoint_id: pc.checkpoint_id,
interrupt_id: pc.interrupt_id,
title: '需要人工确认',
detail: pc.question ?? '执行被中断,等待用户审批',
tool_name: pc.tool_name,
}
} else {
pendingApproval.value = null
}
},
{ immediate: true },
)

// ── 发送消息(SSE 流式) ──
async function sendMessage() {
async function sendMessage(displayText?: string, isResume = false) {
const content = input.value.trim()
if (!content || isLoading.value) return

Expand All @@ -251,15 +276,23 @@ export function useChat() {
return
}

// 延迟清空输入框,避免跳转后问题丢失
// 如果是新会话,等会话创建成功后再清空
const isNewSession = !activeSessionId.value
if (!isNewSession) {
// 已有会话,立即清空

if (isResume) {
// 恢复流程:不 push 用户气泡,审批内容不是新的提问
input.value = ''
} else {
const display = displayText ?? content

// 延迟清空输入框,避免跳转后问题丢失
if (!isNewSession) {
// 已有会话,立即清空
input.value = ''
}

messages.value.push({ id: 'u-' + Date.now(), role: 'user', content: display })
}

messages.value.push({ id: 'u-' + Date.now(), role: 'user', content })
isLoading.value = true
progressText.value = ''
streamContent.value = ''
Expand Down Expand Up @@ -336,7 +369,13 @@ export function useChat() {

switch (evt.type) {
case 'start':
if (evt.message_id) assistantId = evt.message_id
// 恢复流程:复用 interrupt 时的 assistant 块 ID,保持在同一块里
if (interruptedAssistantId) {
assistantId = interruptedAssistantId
interruptedAssistantId = ''
} else if (evt.message_id) {
assistantId = evt.message_id
}
if (evt.sources) finalSources = evt.sources
break

Expand Down Expand Up @@ -415,6 +454,41 @@ export function useChat() {
})
return

case 'interrupt': {
isLoading.value = false
progressText.value = ''
streamContent.value = ''
streamSources.value = []
// streamTimeline 不清空,interrupt 前的步骤保留,恢复后继续累加
const info = evt.interrupt_info ?? {}
const approval: PendingApproval = {
checkpoint_id: evt.checkpoint_id ?? '',
interrupt_id: evt.interrupt_id ?? '',
title: '需要人工确认',
detail: evt.detail ?? (info?.message as string) ?? '执行被中断,等待用户处理',
tool_name: (info?.tool_name as string) ?? '',
target_ref: (info?.target_ref as string) ?? '',
reason: (info?.reason as string) ?? '',
}
pendingApproval.value = approval
// 记录 assistant 块 ID,恢复时 done 事件复用同一块
interruptedAssistantId = assistantId || 'a-' + Date.now()
return
}

case 'clarify': {
isLoading.value = false
progressText.value = ''
const q = evt.clarify?.question ?? evt.detail ?? ''
const opts = evt.clarify?.options ?? []
messages.value.push({
id: 'c-' + Date.now(),
role: 'assistant',
content: q,
})
break
}

case 'done':
streamTimeline.value.forEach(
(s) => s.status === 'running' && (s.status = 'success'),
Expand All @@ -423,19 +497,31 @@ export function useChat() {
finalSources = evt.sources
streamSources.value = evt.sources
}
messages.value.push({
id: assistantId || 'a-' + Date.now(),
role: 'assistant',
content: finalContent,
sources: finalSources,
timeline:
streamTimeline.value.length > 0
? [...streamTimeline.value]
: undefined,
trace_id: traceId,
})
if (streamTimeline.value.length > 0) {
collapsedTimelines.value.add(messages.value.length - 1)
const doneTimeline = streamTimeline.value.length > 0 ? [...streamTimeline.value] : undefined
const finalAssistantId = assistantId || 'a-' + Date.now()
// 恢复流程:assistantId 已存在(interrupt 时 push 过),更新那条而不是新建
const existingIdx = messages.value.findIndex((m) => m.id === finalAssistantId)
if (existingIdx >= 0) {
const updated = [...messages.value]
updated[existingIdx] = {
...updated[existingIdx],
content: finalContent,
sources: finalSources.length > 0 ? finalSources : updated[existingIdx].sources,
timeline: doneTimeline ?? updated[existingIdx].timeline,
trace_id: traceId,
}
messages.value = updated
if (doneTimeline) collapsedTimelines.value.add(existingIdx)
} else {
messages.value.push({
id: finalAssistantId,
role: 'assistant',
content: finalContent,
sources: finalSources,
timeline: doneTimeline,
trace_id: traceId,
})
if (doneTimeline) collapsedTimelines.value.add(messages.value.length - 1)
}
isLoading.value = false
streamContent.value = ''
Expand Down Expand Up @@ -561,6 +647,19 @@ export function useChat() {
}
}

// ── 危险工具审批 ──
function approvePending(resolution: 'approve' | 'reject') {
if (!pendingApproval.value) return
input.value = resolution // 请求内容
pendingApproval.value = null
void sendMessage(undefined, true) // isResume=true: 不 push 用户气泡
}

function cancelApproval() {
pendingApproval.value = null
interruptedAssistantId = ''
}

// ── 反馈 ──
// 提交消息反馈
async function submitFeedback(
Expand Down Expand Up @@ -740,6 +839,9 @@ export function useChat() {
submitFeedback,
newChat,
cleanTooltipText,
pendingApproval,
approvePending,
cancelApproval,
}
}

Expand Down
Loading
Loading