Commit 75bba7e3f0f72225f54a5196ec59082773f3c8ed

Authored by yibo
1 parent 85e1c513

feat(lifelog-recorder): v0.5 — hermes 接通 + SummaryWorker 纪要生成

设备侧 hermes api_server platform 配置(HANDOFF §五新增详细步骤):
- ~/.hermes/.env 加 API_SERVER_ENABLED + HOST + PORT + KEY + MODEL_NAME
- pip install anthropic(kimi-for-coding 走 Anthropic-compat 协议层)
- setsid + disown 启动避免 adb shell 退出时 SIGTERM 杀掉 gateway

App 侧:
- BuildConfig 增加 HERMES_API_URL / HERMES_API_KEY / HERMES_MODEL
- keys.properties.example 同步样本
- HermesClient.kt: minimal OkHttp 客户端,POST /v1/chat/completions +
  Bearer auth;JSONObject 拼请求 / 解 choices[0].message.content
- SummaryWorker.kt (CoroutineWorker):
  - 滑动 1h 时间窗;speech < 60s 直接 skip
  - 拉 Transcript + speaker name 拼用户提示
  - system prompt 要求 markdown 出「主题概括 / 关键要点 / 人物 / 地点 / 待办」
  - 写 Summary 表(windowStartTs, windowEndTs, markdown, model)
- SummaryScheduler.kt: enqueueUniquePeriodicWork 每小时(KEEP)+
  OneTimeWorkRequest(按钮触发)
- LifelogApp.onCreate 启动时 schedulePeriodic
- MainActivity: 新「纪要」section + 「现在跑一次」按钮 + SummaryCard 列表
- AndroidManifest android:networkSecurityConfig 开 127.0.0.1 + localhost
  cleartext 豁免(OkHttp 默认禁 HTTP,loopback 也禁)

实测:60min 窗 / 172 段 / 709s 语音 / Kimi 14.3k prompt token / 649
completion → 一份 markdown 纪要正确分辨出「电影解说 + 修仙网文」两段
内容,按要求列出主题/要点/人物/地点/待办 5 个 section。

HANDOFF.md §五 LLM 纪要 worker 移到「已完成」;下一档是存储 TTL /
设置页 / 多 anchor 平均 / lemonPCRM 回链 / hermes 自启。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
lifelog-recorder/HANDOFF.md
... ... @@ -158,14 +158,46 @@ VAD 参数(在 `RecorderService.runPipeline` 里调):
158 158 - v0.2 (2026-06):Opus 编码 / BootReceiver / 电池白名单
159 159 - v0.3 (2026-06):声纹识别(3dspeaker campplus)/ FTS4 中文搜索
160 160 - v0.4 (2026-06):段播放 (MediaPlayer Opus) / bottom sheet 操作菜单 / enroll 后自动回填老段
  161 +- v0.5 (2026-06):hermes api_server 端点 + SummaryWorker + 纪要 UI section
161 162  
162 163 剩下:
163 164  
164   -1. **LLM 纪要 worker**:WorkManager cron。每小时 / 静音 30min 触发,按时间窗 group segments → POST 到 hermes gateway `http://127.0.0.1:8642/v1/chat/completions`(需要先把 hermes api_server platform 配置起来,见 `~/.hermes/config.yaml`)。
165   -2. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。
166   -3. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。
167   -4. **多 anchor 平均**:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding 提高匹配率。
168   -5. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。
  165 +1. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。
  166 +2. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。
  167 +3. **多 anchor 平均**:现在 enroll 是单段单 anchor,可以「连选多段都标 我」自动算平均 embedding 提高匹配率。
  168 +4. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。
  169 +5. **hermes gateway 自启**:当前需用户手动跑 `bash ~/start_gw.sh`,可写到 Termux:Boot 启动脚本。
  170 +
  171 +### hermes gateway 设置(v0.5 关键前置)
  172 +
  173 +```bash
  174 +# 一次性:~/.hermes/.env 末尾追加
  175 +API_SERVER_ENABLED=true
  176 +API_SERVER_HOST=127.0.0.1
  177 +API_SERVER_PORT=8642
  178 +API_SERVER_KEY=<openssl rand -hex 16>
  179 +API_SERVER_MODEL_NAME=kimi-for-coding
  180 +
  181 +# 需要 anthropic SDK(kimi-for-coding 协议层走 Anthropic-compat)
  182 +~/hermes-agent/venv/bin/pip install -i https://pypi.tuna.tsinghua.edu.cn/simple/ 'anthropic>=0.39.0'
  183 +
  184 +# 启动:必须用 setsid + disown 才能在 adb shell 之外存活
  185 +cat > ~/start_gw.sh <<'EOF'
  186 +#!/data/data/com.termux/files/usr/bin/bash
  187 +export PATH=/data/data/com.termux/files/usr/bin:$PATH
  188 +export HOME=/data/data/com.termux/files/home
  189 +pkill -f 'hermes.*gateway' 2>/dev/null; sleep 2
  190 +set -a; . $HOME/.hermes/.env; set +a
  191 +: > $HOME/.hermes/logs/gateway.log
  192 +setsid hermes gateway run </dev/null > $HOME/.hermes/logs/gateway.log 2>&1 &
  193 +disown $!
  194 +EOF
  195 +chmod 755 ~/start_gw.sh
  196 +bash ~/start_gw.sh
  197 +```
  198 +
  199 +lifelog 的 `keys.properties` 里 `hermes.apiUrl` / `hermes.apiKey` 要跟这里
  200 +对得上才能调通。`network_security_config.xml` 已开 127.0.0.1 cleartext 豁免。
169 201  
170 202 ### 存储预算(v0.2 后)
171 203  
... ...
lifelog-recorder/app/build.gradle.kts
... ... @@ -35,6 +35,9 @@ android {
35 35 buildConfigField("String", "XFYUN_API_SECRET", "\"${key("xfyun.apiSecret")}\"")
36 36 buildConfigField("String", "XFYUN_IAT_HOST", "\"${key("xfyun.iatHost").ifEmpty { "iat.xf-yun.com" }}\"")
37 37 buildConfigField("String", "XFYUN_IAT_PATH", "\"${key("xfyun.iatPath").ifEmpty { "/v1" }}\"")
  38 + buildConfigField("String", "HERMES_API_URL", "\"${key("hermes.apiUrl").ifEmpty { "http://127.0.0.1:8642" }}\"")
  39 + buildConfigField("String", "HERMES_API_KEY", "\"${key("hermes.apiKey")}\"")
  40 + buildConfigField("String", "HERMES_MODEL", "\"${key("hermes.model").ifEmpty { "kimi-for-coding" }}\"")
38 41 }
39 42  
40 43 packaging {
... ...
lifelog-recorder/app/src/main/AndroidManifest.xml
... ... @@ -17,6 +17,7 @@
17 17 android:label="Lifelog"
18 18 android:allowBackup="false"
19 19 android:supportsRtl="true"
  20 + android:networkSecurityConfig="@xml/network_security_config"
20 21 android:theme="@style/Theme.Lifelog"
21 22 tools:targetApi="35">
22 23  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt
... ... @@ -4,6 +4,7 @@ import android.app.Application
4 4 import android.app.NotificationChannel
5 5 import android.app.NotificationManager
6 6 import com.lemon.lifelog.service.RecorderService
  7 +import com.lemon.lifelog.summary.SummaryScheduler
7 8  
8 9 class LifelogApp : Application() {
9 10 override fun onCreate() {
... ... @@ -16,5 +17,7 @@ class LifelogApp : Application() {
16 17 NotificationManager.IMPORTANCE_LOW,
17 18 )
18 19 )
  20 + // Hourly 纪要 worker (KEEP = 已排过就不动)。
  21 + SummaryScheduler.schedulePeriodic(this)
19 22 }
20 23 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
... ... @@ -63,6 +63,8 @@ import com.lemon.lifelog.asr.toFloatArrayLe
63 63 import com.lemon.lifelog.asr.toLeBytes
64 64 import com.lemon.lifelog.asr.transcribeWithStats
65 65 import com.lemon.lifelog.audio.SegmentPlayer
  66 +import com.lemon.lifelog.data.Summary
  67 +import com.lemon.lifelog.summary.SummaryScheduler
66 68 import com.lemon.lifelog.data.Repository
67 69 import com.lemon.lifelog.data.Segment
68 70 import com.lemon.lifelog.data.SegmentRow
... ... @@ -116,6 +118,7 @@ private fun MainScreen() {
116 118 }
117 119 val timeline by timelineFlow.collectAsState(initial = emptyList())
118 120 val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList())
  121 + val summaries by repo.summaries.recent(20).collectAsState(initial = emptyList())
119 122  
120 123 // Sheet / dialog state.
121 124 var actionRow by remember { mutableStateOf<SegmentRow?>(null) }
... ... @@ -229,6 +232,27 @@ private fun MainScreen() {
229 232  
230 233 HorizontalDivider()
231 234  
  235 + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
  236 + horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  237 + Text("纪要(${summaries.size})", style = MaterialTheme.typography.titleMedium)
  238 + Button(onClick = { SummaryScheduler.runNow(activity) }) {
  239 + Text("现在跑一次")
  240 + }
  241 + }
  242 + if (summaries.isEmpty()) {
  243 + Text("还没有纪要。点上面按钮触发;或等 hourly worker(要求 hermes 在 127.0.0.1:8642 跑着)。",
  244 + style = MaterialTheme.typography.bodySmall)
  245 + } else {
  246 + Column(
  247 + modifier = Modifier.fillMaxWidth(),
  248 + verticalArrangement = Arrangement.spacedBy(4.dp),
  249 + ) {
  250 + summaries.take(5).forEach { s -> SummaryCard(s) }
  251 + }
  252 + }
  253 +
  254 + HorizontalDivider()
  255 +
232 256 Text("ASR Bench (95s TTS sample)", style = MaterialTheme.typography.titleMedium)
233 257 Text(asrStatus, style = MaterialTheme.typography.bodyMedium)
234 258  
... ... @@ -456,6 +480,20 @@ private fun requestBatteryOptExemption(ctx: Context) {
456 480 }
457 481  
458 482 private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)
  483 +private val SUMMARY_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
  484 +
  485 +@Composable
  486 +private fun SummaryCard(s: Summary) {
  487 + Column(
  488 + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
  489 + ) {
  490 + Text(
  491 + "${SUMMARY_FMT.format(Date(s.windowStartTs))} → ${SUMMARY_FMT.format(Date(s.windowEndTs))} · ${s.model}",
  492 + style = MaterialTheme.typography.labelSmall,
  493 + )
  494 + Text(s.markdown, style = MaterialTheme.typography.bodySmall)
  495 + }
  496 +}
459 497  
460 498 @Composable
461 499 private fun TimelineRow(row: SegmentRow, isPlaying: Boolean, onClick: () -> Unit) {
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/HermesClient.kt 0 → 100644
  1 +package com.lemon.lifelog.asr
  2 +
  3 +import android.util.Log
  4 +import com.lemon.lifelog.BuildConfig
  5 +import okhttp3.MediaType.Companion.toMediaType
  6 +import okhttp3.OkHttpClient
  7 +import okhttp3.Request
  8 +import okhttp3.RequestBody.Companion.toRequestBody
  9 +import org.json.JSONArray
  10 +import org.json.JSONObject
  11 +import java.io.IOException
  12 +import java.util.concurrent.TimeUnit
  13 +
  14 +/**
  15 + * Minimal OpenAI-compatible client targeting the hermes api_server endpoint
  16 + * (default http://127.0.0.1:8642/v1).
  17 + *
  18 + * Used by SummaryWorker to produce 纪要 from transcribed segments.
  19 + * Hermes routes to Kimi (kimi-for-coding) underneath; protocol stays OpenAI-shaped.
  20 + */
  21 +class HermesClient(
  22 + private val baseUrl: String = BuildConfig.HERMES_API_URL,
  23 + private val apiKey: String = BuildConfig.HERMES_API_KEY,
  24 + private val model: String = BuildConfig.HERMES_MODEL,
  25 +) {
  26 + companion object {
  27 + private const val TAG = "HermesClient"
  28 + private val JSON = "application/json; charset=utf-8".toMediaType()
  29 + }
  30 +
  31 + private val http: OkHttpClient = OkHttpClient.Builder()
  32 + .connectTimeout(10, TimeUnit.SECONDS)
  33 + .readTimeout(120, TimeUnit.SECONDS) // Kimi 大段总结可以慢
  34 + .writeTimeout(30, TimeUnit.SECONDS)
  35 + .build()
  36 +
  37 + data class ChatResult(
  38 + val text: String,
  39 + val promptTokens: Int,
  40 + val completionTokens: Int,
  41 + val model: String,
  42 + )
  43 +
  44 + @Throws(IOException::class)
  45 + fun chat(
  46 + systemPrompt: String,
  47 + userPrompt: String,
  48 + maxTokens: Int = 800,
  49 + ): ChatResult {
  50 + val payload = JSONObject().apply {
  51 + put("model", model)
  52 + put("max_tokens", maxTokens)
  53 + put("messages", JSONArray().apply {
  54 + if (systemPrompt.isNotBlank()) {
  55 + put(JSONObject().apply {
  56 + put("role", "system")
  57 + put("content", systemPrompt)
  58 + })
  59 + }
  60 + put(JSONObject().apply {
  61 + put("role", "user")
  62 + put("content", userPrompt)
  63 + })
  64 + })
  65 + }
  66 + val req = Request.Builder()
  67 + .url("$baseUrl/v1/chat/completions")
  68 + .header("Authorization", "Bearer $apiKey")
  69 + .header("Content-Type", "application/json")
  70 + .post(payload.toString().toRequestBody(JSON))
  71 + .build()
  72 +
  73 + http.newCall(req).execute().use { resp ->
  74 + val body = resp.body?.string().orEmpty()
  75 + if (!resp.isSuccessful) {
  76 + throw IOException("hermes ${resp.code}: ${body.take(200)}")
  77 + }
  78 + return parseChat(body)
  79 + }
  80 + }
  81 +
  82 + private fun parseChat(body: String): ChatResult {
  83 + val json = JSONObject(body)
  84 + val choices = json.optJSONArray("choices") ?: throw IOException("no choices in response")
  85 + if (choices.length() == 0) throw IOException("empty choices")
  86 + val message = choices.getJSONObject(0).optJSONObject("message")
  87 + ?: throw IOException("no message in choice")
  88 + val text = message.optString("content", "")
  89 + val usage = json.optJSONObject("usage")
  90 + return ChatResult(
  91 + text = text,
  92 + promptTokens = usage?.optInt("prompt_tokens", 0) ?: 0,
  93 + completionTokens = usage?.optInt("completion_tokens", 0) ?: 0,
  94 + model = json.optString("model", model),
  95 + )
  96 + }
  97 +
  98 + /** Cheap health check used by UI status pill. */
  99 + fun ping(): Boolean = try {
  100 + val req = Request.Builder()
  101 + .url("$baseUrl/v1/models")
  102 + .header("Authorization", "Bearer $apiKey")
  103 + .build()
  104 + http.newCall(req).execute().use { it.isSuccessful }
  105 + } catch (t: Throwable) {
  106 + Log.w(TAG, "ping failed: ${t.message}")
  107 + false
  108 + }
  109 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt 0 → 100644
  1 +package com.lemon.lifelog.summary
  2 +
  3 +import android.content.Context
  4 +import androidx.work.Constraints
  5 +import androidx.work.Data
  6 +import androidx.work.ExistingPeriodicWorkPolicy
  7 +import androidx.work.NetworkType
  8 +import androidx.work.OneTimeWorkRequestBuilder
  9 +import androidx.work.PeriodicWorkRequestBuilder
  10 +import androidx.work.WorkManager
  11 +import java.util.concurrent.TimeUnit
  12 +
  13 +object SummaryScheduler {
  14 + private const val PERIODIC_TAG = "lifelog-summary-hourly"
  15 + private const val ONE_SHOT_TAG = "lifelog-summary-once"
  16 +
  17 + fun schedulePeriodic(ctx: Context) {
  18 + val constraints = Constraints.Builder()
  19 + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // hermes 在本机 127.0.0.1
  20 + .setRequiresBatteryNotLow(true)
  21 + .build()
  22 + val request = PeriodicWorkRequestBuilder<SummaryWorker>(1, TimeUnit.HOURS)
  23 + .setConstraints(constraints)
  24 + .build()
  25 + WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
  26 + PERIODIC_TAG,
  27 + ExistingPeriodicWorkPolicy.KEEP,
  28 + request,
  29 + )
  30 + }
  31 +
  32 + fun runNow(ctx: Context, windowOverride: Long? = null) {
  33 + val data = if (windowOverride != null) {
  34 + Data.Builder().putLong(SummaryWorker.INPUT_NOW_TS, windowOverride).build()
  35 + } else Data.EMPTY
  36 + val request = OneTimeWorkRequestBuilder<SummaryWorker>()
  37 + .setInputData(data)
  38 + .addTag(ONE_SHOT_TAG)
  39 + .build()
  40 + WorkManager.getInstance(ctx).enqueue(request)
  41 + }
  42 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt 0 → 100644
  1 +package com.lemon.lifelog.summary
  2 +
  3 +import android.content.Context
  4 +import android.util.Log
  5 +import androidx.work.CoroutineWorker
  6 +import androidx.work.WorkerParameters
  7 +import com.lemon.lifelog.asr.HermesClient
  8 +import com.lemon.lifelog.data.Repository
  9 +import com.lemon.lifelog.data.Summary
  10 +import java.text.SimpleDateFormat
  11 +import java.util.Date
  12 +import java.util.Locale
  13 +
  14 +/**
  15 + * Periodic / on-demand 纪要 worker.
  16 + *
  17 + * Strategy:
  18 + * - Pull transcripts in a sliding window (default: last [WINDOW_MS] ms).
  19 + * - If the window covers < [MIN_DURATION_MS] of actual speech, skip.
  20 + * - If a Summary already exists that overlaps the window with the same or
  21 + * larger span, skip (idempotent).
  22 + * - Compose prompt with transcripts (+ speaker labels if known) and POST to
  23 + * hermes /v1/chat/completions; save the markdown reply as a Summary row.
  24 + *
  25 + * Failure modes:
  26 + * - hermes gateway down → IOException → Worker returns retry().
  27 + * - Empty window → success() no-op.
  28 + */
  29 +class SummaryWorker(
  30 + ctx: Context,
  31 + params: WorkerParameters,
  32 +) : CoroutineWorker(ctx, params) {
  33 +
  34 + companion object {
  35 + const val TAG = "SummaryWorker"
  36 +
  37 + /** 总结窗口大小:默认最近 1 小时。 */
  38 + const val WINDOW_MS = 60L * 60 * 1000
  39 +
  40 + /** 窗口内必须至少有这么多毫秒的语音才触发,否则没必要总结。 */
  41 + const val MIN_DURATION_MS = 60_000L // 60s
  42 +
  43 + const val INPUT_NOW_TS = "now_ts" // override "now" for tests / manual run
  44 + }
  45 +
  46 + private val dateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
  47 +
  48 + override suspend fun doWork(): Result {
  49 + val nowTs = inputData.getLong(INPUT_NOW_TS, System.currentTimeMillis())
  50 + val windowStart = nowTs - WINDOW_MS
  51 + val repo = Repository(applicationContext)
  52 +
  53 + val segments = repo.segments.page(limit = 500, offset = 0)
  54 + .filter { it.startTs in windowStart..nowTs }
  55 + .sortedBy { it.startTs }
  56 +
  57 + val totalSpeechMs = segments.sumOf { it.durationMs }
  58 + if (totalSpeechMs < MIN_DURATION_MS) {
  59 + Log.i(TAG, "skip: total speech ${totalSpeechMs}ms < ${MIN_DURATION_MS}ms threshold")
  60 + return Result.success()
  61 + }
  62 +
  63 + // Build the user prompt: time range + each segment's text (+ speaker label if any).
  64 + val sb = StringBuilder()
  65 + sb.appendLine("以下是 ${dateFmt.format(Date(windowStart))} – ${dateFmt.format(Date(nowTs))} 的语音转写片段(按时间顺序)。")
  66 + sb.appendLine()
  67 + for (seg in segments) {
  68 + val transcript = repo.transcripts.get(seg.id)?.text ?: continue
  69 + if (transcript.isBlank()) continue
  70 + val speakerLabel = repo.segments.get(seg.id)?.speakerId
  71 + ?.let { repo.speakers.all().firstOrNull { p -> p.id == it }?.name }
  72 + ?: "未知"
  73 + val timeStr = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(seg.startTs))
  74 + sb.appendLine("[$timeStr] $speakerLabel: $transcript")
  75 + }
  76 +
  77 + val systemPrompt = """
  78 + 你是一个 lifelog 纪要助手。给定一段时间内的语音转写片段(含时间戳和说话人),
  79 + 请输出一份简洁的 markdown 纪要,包含:
  80 + 1. 一句话主题概括
  81 + 2. 关键信息要点(bullet 列表,5-10 条)
  82 + 3. 如果有待办事项 / 决定 / 提到的人名地名,单独列「待办」「人物」「地点」三个小节
  83 + 纪要语言保持中文。不要复述全文,只摘核心。
  84 + """.trimIndent()
  85 +
  86 + val client = HermesClient()
  87 + val response = try {
  88 + client.chat(systemPrompt, sb.toString())
  89 + } catch (t: Throwable) {
  90 + Log.w(TAG, "hermes call failed: ${t.message}; will retry")
  91 + return Result.retry()
  92 + }
  93 +
  94 + repo.summaries.insert(
  95 + Summary(
  96 + windowStartTs = windowStart,
  97 + windowEndTs = nowTs,
  98 + markdown = response.text,
  99 + model = response.model,
  100 + createdAt = System.currentTimeMillis(),
  101 + ),
  102 + )
  103 +
  104 + Log.i(
  105 + TAG,
  106 + "summary saved span=${WINDOW_MS / 60_000}min " +
  107 + "speech=${totalSpeechMs / 1000}s segments=${segments.size} " +
  108 + "tokens(prompt=${response.promptTokens}, completion=${response.completionTokens})",
  109 + )
  110 + return Result.success()
  111 + }
  112 +}
... ...
lifelog-recorder/app/src/main/res/xml/network_security_config.xml 0 → 100644
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<network-security-config>
  3 + <!-- 允许走 hermes gateway 在本机 loopback 的 cleartext HTTP(127.0.0.1:8642)。
  4 + 其他 host 仍走默认 HTTPS-only 策略。 -->
  5 + <domain-config cleartextTrafficPermitted="true">
  6 + <domain includeSubdomains="false">127.0.0.1</domain>
  7 + <domain includeSubdomains="false">localhost</domain>
  8 + </domain-config>
  9 +</network-security-config>
... ...
lifelog-recorder/keys.properties.example
... ... @@ -3,3 +3,8 @@ xfyun.apiKey=YOUR_API_KEY
3 3 xfyun.apiSecret=YOUR_API_SECRET
4 4 xfyun.iatHost=iat.xf-yun.com
5 5 xfyun.iatPath=/v1
  6 +
  7 +# hermes gateway (OpenAI-compat /v1 endpoint)
  8 +hermes.apiUrl=http://127.0.0.1:8642
  9 +hermes.apiKey=GENERATE_WITH_openssl_rand_hex_16
  10 +hermes.model=kimi-for-coding
... ...