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
43 changes: 42 additions & 1 deletion internal/api/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,28 @@ type Prompt struct {
Data json.RawMessage `json:"data"`
}

// PromptStore 保存 Playground 对话
// SystemInstructionPreset 是一条保存的系统指令预设
type SystemInstructionPreset struct {
ID string `json:"id"`
Title string `json:"title"`
Text string `json:"text"`
}

// PromptStore 保存 Playground 对话与系统指令预设
type PromptStore interface {
Prompts(ctx context.Context, pageSize int, pageToken string) ([]PromptSummary, string, error)
Prompt(context.Context, string) (Prompt, error)
SavePrompt(context.Context, Prompt) (PromptSummary, error)
RenamePrompt(ctx context.Context, id, title, description string) (PromptSummary, error)
CopyPrompt(ctx context.Context, source, id, title string) (PromptSummary, error)
DeletePrompt(context.Context, string) error
SystemInstructions(context.Context) ([]SystemInstructionPreset, error)
SaveSystemInstructions(context.Context, []SystemInstructionPreset) error
}

func (s *server) registerPrompts(mux *http.ServeMux) {
mux.HandleFunc("GET /api/prompts/system-instructions", s.handleSystemInstructions)
mux.HandleFunc("PUT /api/prompts/system-instructions", s.handleSaveSystemInstructions)
mux.HandleFunc("GET /api/prompts", s.handlePrompts)
mux.HandleFunc("GET /api/prompts/{id}", s.handlePrompt)
mux.HandleFunc("PUT /api/prompts/{id}", s.handleSavePrompt)
Expand Down Expand Up @@ -194,6 +205,36 @@ func (s *server) handleDeletePrompt(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}

// handleSystemInstructions 返回保存在对话库中的系统指令预设列表
func (s *server) handleSystemInstructions(w http.ResponseWriter, r *http.Request) {
presets, err := s.config.Prompts.SystemInstructions(r.Context())
if err != nil {
writeAdminError(w, http.StatusInternalServerError, "prompt_store_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, struct {
Presets []SystemInstructionPreset `json:"presets"`
}{presets})
}

// handleSaveSystemInstructions 保存系统指令预设列表
func (s *server) handleSaveSystemInstructions(w http.ResponseWriter, r *http.Request) {
var body struct {
Presets []SystemInstructionPreset `json:"presets"`
}
if err := decodeJSON(r, &body); err != nil {
writeAdminError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := s.config.Prompts.SaveSystemInstructions(r.Context(), body.Presets); err != nil {
writeAdminError(w, http.StatusInternalServerError, "prompt_store_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, struct {
Presets []SystemInstructionPreset `json:"presets"`
}{body.Presets})
}

// writePromptError 把对话库错误写为管理端错误
func writePromptError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrPromptNotFound) {
Expand Down
40 changes: 39 additions & 1 deletion internal/promptdb/promptdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ const schema = `
CREATE TABLE IF NOT EXISTS prompts (
id TEXT PRIMARY KEY, title TEXT NOT NULL, model TEXT NOT NULL, description TEXT NOT NULL,
created INTEGER NOT NULL, updated INTEGER NOT NULL, data BLOB NOT NULL);
CREATE INDEX IF NOT EXISTS prompts_updated ON prompts(updated);`
CREATE INDEX IF NOT EXISTS prompts_updated ON prompts(updated);
CREATE TABLE IF NOT EXISTS prompt_settings (
key TEXT PRIMARY KEY, data BLOB NOT NULL);`

// summaryColumns 是对话列表项的列
const summaryColumns = `id, title, model, description, created, updated`
Expand Down Expand Up @@ -145,6 +147,42 @@ func (s *Store) DeletePrompt(ctx context.Context, id string) error {
return sqlitedb.Reclaim(ctx, s.db)
}

const systemInstructionsKey = "system_instructions"

// SystemInstructions 读取保存的系统指令预设列表
func (s *Store) SystemInstructions(ctx context.Context) ([]api.SystemInstructionPreset, error) {
var raw []byte
err := s.db.QueryRowContext(ctx, `SELECT data FROM prompt_settings WHERE key = ?`, systemInstructionsKey).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return []api.SystemInstructionPreset{}, nil
}
if err != nil {
return nil, err
}
var presets []api.SystemInstructionPreset
if err := json.Unmarshal(raw, &presets); err != nil {
return nil, err
}
if presets == nil {
presets = []api.SystemInstructionPreset{}
}
return presets, nil
}

// SaveSystemInstructions 写入系统指令预设列表
func (s *Store) SaveSystemInstructions(ctx context.Context, presets []api.SystemInstructionPreset) error {
if presets == nil {
presets = []api.SystemInstructionPreset{}
}
raw, err := json.Marshal(presets)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `INSERT INTO prompt_settings (key, data) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET data = excluded.data`, systemInstructionsKey, raw)
return err
}

// scanSummary 读取一行对话列表项,没有行时返回 api.ErrPromptNotFound
func scanSummary(row interface{ Scan(...any) error }) (api.PromptSummary, error) {
var item api.PromptSummary
Expand Down
14 changes: 14 additions & 0 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
PromptInput,
PromptSummary,
SavedPrompt,
SystemInstructionPreset,
Cooldown,
RequestBody,
RequestSummary,
Expand Down Expand Up @@ -286,6 +287,19 @@ export const api = {
body: jsonBody({ model, prompt, answer }),
})
).title,
systemInstructions: async () =>
(await requestJSON<{ presets: SystemInstructionPreset[] }>('/api/prompts/system-instructions'))
.presets,
saveSystemInstructions: async (presets: SystemInstructionPreset[]) =>
(
await requestJSON<{ presets: SystemInstructionPreset[] }>(
'/api/prompts/system-instructions',
{
method: 'PUT',
body: jsonBody({ presets }),
},
)
).presets,
}

// openAdminEvents 建立唯一的管理状态 SSE 连接
Expand Down
54 changes: 38 additions & 16 deletions web/src/playground/ChatTurn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ const fullTime = computed(() =>
const roleLabel = computed(() => (props.turn.role === 'user' ? t('pg.user') : t('pg.model')))
// hasThoughtSegment 表示模型轮次单独显示思考段
const hasThoughtSegment = computed(() => props.turn.role === 'model' && thoughtText.value !== '')
// hasStickyCode 表示回复段含有块级代码标题,供操作栏避让粘性标题而无需全树匹配 :has(.ms-code-block)
const hasStickyCode = computed(() =>
answerParts.value.some(
(part) =>
part.functionCall !== undefined ||
part.executableCode !== undefined ||
part.codeExecutionResult !== undefined ||
(part.text !== undefined && (part.text.includes('```') || part.text.includes('~~~'))),
),
)
// hasAnswerSegment 表示需要显示回复段
const hasAnswerSegment = computed(
() =>
Expand All @@ -116,14 +126,17 @@ const hasAnswerSegment = computed(
waiting.value ||
(props.turn.sources?.length ?? 0) > 0,
)
// now 是生成中每 100 ms 更新的时刻,轮次耗时与官网一样随之计数
// now 是生成中每 200 ms 更新的时刻,轮次耗时随之计数
const now = ref(performance.now())
let ticker = 0
watch(
() => props.streaming,
(running) => {
window.clearInterval(ticker)
if (running) ticker = window.setInterval(() => (now.value = performance.now()), 100)
if (running) {
now.value = performance.now()
ticker = window.setInterval(() => (now.value = performance.now()), 200)
}
},
{ immediate: true },
)
Expand Down Expand Up @@ -265,13 +278,15 @@ let resize: ResizeObserver | undefined

onMounted(() => {
intersection = new IntersectionObserver(
([entry]) => {
visible.value = entry?.isIntersecting ?? true
(entries) => {
const last = entries.at(-1)
if (last) visible.value = last.isIntersecting
},
{ root: root.value?.closest('.chat-scroller') ?? null, rootMargin: '1500px 0px' },
)
resize = new ResizeObserver(([entry]) => {
if (visible.value && entry) height.value = entry.contentRect.height
resize = new ResizeObserver((entries) => {
const rect = entries.at(-1)?.contentRect
if (visible.value && rect && rect.height > 0) height.value = rect.height
})
if (root.value) {
intersection.observe(root.value)
Expand Down Expand Up @@ -368,7 +383,7 @@ onUnmounted(() => {
v-if="hasAnswerSegment"
tabindex="-1"
class="chat-turn-container"
:class="[turn.role, { edit: editing }]"
:class="[turn.role, { edit: editing, 'has-code': hasStickyCode }]"
>
<div class="actions-container">
<div class="actions hover-or-edit">
Expand Down Expand Up @@ -626,6 +641,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
width: 100%;
contain: layout style;
}

.chat-turn + .chat-turn {
Expand All @@ -651,7 +667,7 @@ onUnmounted(() => {
}

.chat-turn-container:focus-visible,
.chat-turn-container:has([aria-expanded='true'][aria-haspopup='menu']):not(.edit) {
.chat-turn-container:not(.edit):has(> .actions-container [aria-expanded='true']) {
border-color: var(--color-v3-outline);
}

Expand All @@ -660,12 +676,13 @@ onUnmounted(() => {
border-color: var(--color-v3-outline);
}

.chat-turn-container:hover .hover-or-edit {
.chat-turn-container:hover > .actions-container .hover-or-edit,
.chat-turn-container:hover > .turn-footer .hover-or-edit {
opacity: 1;
pointer-events: auto;
}

.chat-turn-container:hover .turn-footer {
.chat-turn-container:hover > .turn-footer {
background-color: var(--color-v3-surface);
}
}
Expand All @@ -684,7 +701,7 @@ onUnmounted(() => {
height: 0;
}

.chat-turn-container:has(.ms-code-block .code-header.sticky) .actions-container {
.chat-turn-container.has-code > .actions-container {
top: calc(56px * var(--ms-autoscroll-container-scrolled, 1));
transition: top 0.6s ease-in-out;
}
Expand All @@ -701,11 +718,11 @@ onUnmounted(() => {
background-color: var(--color-v3-surface-container-high);
}

.chat-turn-container.model:has(.ms-code-block .code-header.sticky) .actions {
.chat-turn-container.model.has-code > .actions-container .actions {
right: 8px;
}

.chat-turn-container.user:has(.ms-code-block .code-header.sticky) .actions {
.chat-turn-container.user.has-code > .actions-container .actions {
right: 20px;
}

Expand All @@ -715,9 +732,14 @@ onUnmounted(() => {
transition: opacity 0.3s ease-in-out;
}

.chat-turn-container:focus-within .hover-or-edit,
.chat-turn-container:has([aria-expanded='true'][aria-haspopup='menu']) .hover-or-edit,
.chat-turn-container.edit .hover-or-edit {
.chat-turn-container:focus-within > .actions-container .hover-or-edit,
.chat-turn-container:focus-within > .turn-footer .hover-or-edit,
.chat-turn-container:has(> .actions-container [aria-expanded='true'])
> .actions-container
.hover-or-edit,
.chat-turn-container:has(> .actions-container [aria-expanded='true']) > .turn-footer .hover-or-edit,
.chat-turn-container.edit > .actions-container .hover-or-edit,
.chat-turn-container.edit > .turn-footer .hover-or-edit {
opacity: 1;
pointer-events: auto;
}
Expand Down
Loading
Loading